feat: add TypeRT remote gateway infrastructure
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.13",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
EnumMemberModel,
|
||||
ExportModel,
|
||||
FaceModel,
|
||||
InvocationModel,
|
||||
InvocationParameterModel,
|
||||
JsDocTagModel,
|
||||
KeywordTypeName,
|
||||
MemberBase,
|
||||
@@ -23,6 +25,8 @@ import type {
|
||||
ObjectModel,
|
||||
PackageModel,
|
||||
ParameterModel,
|
||||
RemoteBoundaryModel,
|
||||
RemoteTypeImportModel,
|
||||
SchemaModel,
|
||||
ServiceModel,
|
||||
SignatureModel,
|
||||
@@ -122,6 +126,25 @@ interface ModuleIdentity {
|
||||
readonly subpath: string
|
||||
}
|
||||
|
||||
interface StaticLookupDeclaration {
|
||||
readonly key: string
|
||||
readonly hostSymbol: SymbolId
|
||||
readonly wireType: ts.TypeNode
|
||||
readonly site: ts.Node
|
||||
}
|
||||
|
||||
interface StaticContextDeclaration {
|
||||
readonly key: string
|
||||
readonly wireType: ts.TypeNode
|
||||
readonly site: ts.Node
|
||||
}
|
||||
|
||||
interface GatewayBinding {
|
||||
readonly service: string
|
||||
readonly namespace: string
|
||||
readonly site: ts.PropertyDeclaration
|
||||
}
|
||||
|
||||
type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode
|
||||
|
||||
const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] }
|
||||
@@ -453,15 +476,11 @@ export class WorkspaceAnalyzer {
|
||||
config: this.caches.config(configPath),
|
||||
manifest,
|
||||
}
|
||||
const packagePath = slash(relative(this.options.root, packageRoot))
|
||||
const clientPackage = packagePath === 'packages/client' || packagePath.startsWith('packages/client/')
|
||||
if (clientPackage && isDualFacePackage(manifest)) {
|
||||
if (isDualFacePackage(manifest)) {
|
||||
registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) })
|
||||
registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) })
|
||||
} else if (clientPackage) {
|
||||
registrations.push({ ...registration, face: 'client' })
|
||||
} else {
|
||||
registrations.push({ ...registration, face: 'host' })
|
||||
registrations.push(registration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,6 +499,7 @@ export class WorkspaceAnalyzer {
|
||||
&& subpath !== './package.json'
|
||||
&& subpath !== './typert'
|
||||
&& subpath !== './client/typert'
|
||||
&& subpath !== './remote'
|
||||
&& !target.endsWith('.json'))
|
||||
.map(([, target]) => sourcePathForExport(registration.root, target))
|
||||
.filter(existsSync)
|
||||
@@ -578,6 +598,8 @@ class FaceAnalyzer {
|
||||
private readonly nodes = new Map<TypeNodeId, TypeNodeModel>()
|
||||
private readonly exportsByPackage = new Map<string, ExportRecord[]>()
|
||||
private readonly nodeOrdinals = new Map<string, number>()
|
||||
private staticLookups: readonly StaticLookupDeclaration[] | undefined
|
||||
private staticContexts: ReadonlyMap<string, StaticContextDeclaration> | undefined
|
||||
|
||||
constructor(options: FaceAnalyzerOptions) {
|
||||
this.root = options.root
|
||||
@@ -601,6 +623,7 @@ class FaceAnalyzer {
|
||||
const packages = this.registrations
|
||||
.map(registration => this.analyzePackage(registration))
|
||||
.filter(hasPackageSurface)
|
||||
this.validateInvocationIdentity(packages)
|
||||
return {
|
||||
face: this.face,
|
||||
packages,
|
||||
@@ -634,6 +657,7 @@ class FaceAnalyzer {
|
||||
}
|
||||
}
|
||||
}
|
||||
const explicitServices = this.collectExplicitServices(records)
|
||||
|
||||
const objects: ObjectModel[] = []
|
||||
const schemas: SchemaModel[] = []
|
||||
@@ -672,10 +696,14 @@ class FaceAnalyzer {
|
||||
root: slash(relative(this.root, registration.root)),
|
||||
exports: records.map(record => record.model)
|
||||
.sort((left, right) => left.subpath.localeCompare(right.subpath) || left.name.localeCompare(right.name)),
|
||||
services: uniqueBy(services, service => service.key).sort((left, right) => left.key.localeCompare(right.key)),
|
||||
services: uniqueBy([...explicitServices, ...services], service => service.key)
|
||||
.sort((left, right) => left.key.localeCompare(right.key)),
|
||||
events: uniqueBy(events, event => event.name).sort((left, right) => left.name.localeCompare(right.name)),
|
||||
objects: objects.sort((left, right) => left.export.name.localeCompare(right.export.name)),
|
||||
schemas: schemas.sort((left, right) => left.export.name.localeCompare(right.export.name)),
|
||||
invocations: this.face === 'host'
|
||||
? this.collectInvocations(registration, reachable).sort((left, right) => left.id.localeCompare(right.id))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,7 +714,7 @@ class FaceAnalyzer {
|
||||
const records: ExportRecord[] = []
|
||||
for (const [subpath, target] of targets) {
|
||||
if (target.includes('*') || subpath === './package.json'
|
||||
|| subpath === './typert' || subpath === './client/typert'
|
||||
|| subpath === './typert' || subpath === './client/typert' || subpath === './remote'
|
||||
// Data exports (bundle patch lists, JSON manifests) carry no TypeScript API.
|
||||
|| target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue
|
||||
const sourcePath = sourcePathForExport(registration.root, target)
|
||||
@@ -849,6 +877,740 @@ class FaceAnalyzer {
|
||||
return result
|
||||
}
|
||||
|
||||
private collectExplicitServices(records: readonly ExportRecord[]): ServiceModel[] {
|
||||
const result: ServiceModel[] = []
|
||||
const seen = new Set<SymbolId>()
|
||||
for (const record of records) {
|
||||
const tag = typertServiceTag(record.declaration)
|
||||
if (tag === undefined) continue
|
||||
const words = (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/)
|
||||
if (words.length !== 2 || !isRemoteSegment(words[1] ?? '')) {
|
||||
this.fail(tag, '@typert service requires exactly one nonempty Cordis service key without "/"')
|
||||
}
|
||||
if (!ts.isClassDeclaration(record.declaration)) {
|
||||
this.fail(record.declaration, '@typert service requires an exported class')
|
||||
}
|
||||
const symbol = this.resolveSymbol(record.symbol)
|
||||
const symbolId = this.symbolId(symbol)
|
||||
if (seen.has(symbolId)) continue
|
||||
seen.add(symbolId)
|
||||
const model = this.ensureDeclaration(symbol, record.declaration)
|
||||
result.push({
|
||||
...documentationOf(record.declaration),
|
||||
key: words[1] as string,
|
||||
symbol: symbolId,
|
||||
export: record.model,
|
||||
members: model.members.filter(exposableMember).map(member => member.id),
|
||||
location: this.location(record.declaration),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private collectInvocations(
|
||||
registration: PackageRegistration,
|
||||
reachable: readonly ts.SourceFile[],
|
||||
): InvocationModel[] {
|
||||
const result: InvocationModel[] = []
|
||||
for (const sourceFile of reachable) {
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isClassDeclaration(statement)) continue
|
||||
const marked = statement.members.flatMap((member) => {
|
||||
const invocation = this.remoteMarker(member)
|
||||
if (invocation === undefined) return []
|
||||
if (!ts.isMethodDeclaration(member)) {
|
||||
this.fail(member, 'Remote decorators require a public instance method')
|
||||
}
|
||||
return [{ method: member, invocation }]
|
||||
})
|
||||
const first = marked[0]
|
||||
if (first === undefined) continue
|
||||
const binding = this.gatewayBinding(statement)
|
||||
if (binding === undefined) {
|
||||
this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)')
|
||||
}
|
||||
for (const { method, invocation } of marked) {
|
||||
result.push(this.invocationModel(registration, binding, method, invocation))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private invocationModel(
|
||||
registration: PackageRegistration,
|
||||
binding: GatewayBinding,
|
||||
method: ts.MethodDeclaration,
|
||||
invocation:
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string },
|
||||
): InvocationModel {
|
||||
if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) {
|
||||
this.fail(method, 'Remote decorators require a public instance method')
|
||||
}
|
||||
if (hasModifier(method, ts.SyntaxKind.AbstractKeyword) || method.body === undefined) {
|
||||
this.fail(method, 'Remote methods must have a concrete implementation')
|
||||
}
|
||||
if (!ts.isIdentifier(method.name)) {
|
||||
this.fail(method, 'Remote method names must be identifiers')
|
||||
}
|
||||
if ((method.typeParameters?.length ?? 0) > 0) {
|
||||
this.fail(method, 'generic Remote methods are not supported')
|
||||
}
|
||||
const methodName = method.name.text
|
||||
const exportedMethod = invocation.exportName ?? methodName
|
||||
|
||||
const lookups = this.lookupDeclarations()
|
||||
const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup]))
|
||||
const parameters: InvocationParameterModel[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const parameter of method.parameters) {
|
||||
if (!ts.isIdentifier(parameter.name)) {
|
||||
this.fail(parameter, 'Remote parameters must use identifier bindings')
|
||||
}
|
||||
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 authoredType = this.requiredType(parameter, parameter.type, 'parameter')
|
||||
const hostSymbol = this.symbolAtType(authoredType)
|
||||
const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol))
|
||||
let modeled: InvocationParameterModel
|
||||
if (lookup !== undefined) {
|
||||
if (parameter.name.text !== lookup.key) {
|
||||
this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`)
|
||||
}
|
||||
const boundary = this.remoteBoundary(
|
||||
lookup.wireType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:${lookup.key}Id`,
|
||||
true,
|
||||
)
|
||||
modeled = {
|
||||
name: parameter.name.text,
|
||||
wire: `${lookup.key}Id`,
|
||||
source: 'lookup',
|
||||
lookup: lookup.key,
|
||||
boundary,
|
||||
}
|
||||
} else {
|
||||
if (hostSymbol !== undefined && this.isWorkspaceClass(hostSymbol)) {
|
||||
this.fail(parameter, `non-JSON class parameter ${hostSymbol.name} requires a TypeRTLookupMap entry`)
|
||||
}
|
||||
modeled = {
|
||||
name: parameter.name.text,
|
||||
wire: parameter.name.text,
|
||||
source: 'json',
|
||||
boundary: this.remoteBoundary(
|
||||
authoredType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`,
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (wires.has(modeled.wire)) this.fail(parameter, `duplicate Remote wire field ${modeled.wire}`)
|
||||
wires.add(modeled.wire)
|
||||
parameters.push(modeled)
|
||||
}
|
||||
|
||||
let receiver: InvocationModel['invocation'] = { kind: 'direct' }
|
||||
if (invocation.kind === 'context') {
|
||||
const context = this.contextDeclarations().get(invocation.context)
|
||||
if (context === undefined) {
|
||||
this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`)
|
||||
}
|
||||
const wire = `${invocation.context}Id`
|
||||
if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`)
|
||||
receiver = {
|
||||
kind: 'context',
|
||||
context: invocation.context,
|
||||
wire,
|
||||
boundary: this.remoteBoundary(
|
||||
context.wireType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:${wire}`,
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let scope: InvocationModel['scope']
|
||||
if (invocation.kind === 'direct') {
|
||||
const lookupParameters = parameters.filter(parameter => parameter.source === 'lookup')
|
||||
const parameter = lookupParameters.length === 1 ? lookupParameters[0] : undefined
|
||||
const context = parameter?.lookup === undefined
|
||||
? undefined
|
||||
: this.contextDeclarations().get(parameter.lookup)
|
||||
if (parameter !== undefined && context !== undefined) {
|
||||
const contextBoundary = this.remoteBoundary(
|
||||
context.wireType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:scope:${context.key}`,
|
||||
true,
|
||||
)
|
||||
if (contextBoundary.typeSymbol !== parameter.boundary.typeSymbol) {
|
||||
this.fail(
|
||||
method,
|
||||
`Remote scope ${context.key} wire type ${contextBoundary.typeSymbol} does not match lookup wire type ${parameter.boundary.typeSymbol}`,
|
||||
)
|
||||
}
|
||||
scope = { context: context.key, wire: parameter.wire }
|
||||
}
|
||||
}
|
||||
|
||||
const resultType = this.remoteResultType(method)
|
||||
return {
|
||||
id: `${registration.name}#${binding.namespace}/${exportedMethod}`,
|
||||
service: binding.service,
|
||||
namespace: binding.namespace,
|
||||
method: exportedMethod,
|
||||
...(exportedMethod === methodName ? {} : { implementation: methodName }),
|
||||
invocation: receiver,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
parameters,
|
||||
result: this.remoteBoundary(
|
||||
resultType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:result`,
|
||||
false,
|
||||
),
|
||||
location: this.location(method.name),
|
||||
}
|
||||
}
|
||||
|
||||
private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined {
|
||||
const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration =>
|
||||
ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway')
|
||||
const [property, duplicate] = candidates
|
||||
if (property === undefined) return undefined
|
||||
if (duplicate !== undefined) this.fail(duplicate, 'Service has more than one typertGateway field')
|
||||
if (visibilityOf(property) !== 'public'
|
||||
|| hasModifier(property, ts.SyntaxKind.StaticKeyword)
|
||||
|| !hasModifier(property, ts.SyntaxKind.ReadonlyKeyword)) {
|
||||
this.fail(property, 'typertGateway must be a public readonly instance field')
|
||||
}
|
||||
if (property.initializer === undefined
|
||||
|| !ts.isCallExpression(property.initializer)
|
||||
|| !this.isTypeMetaSymbol(property.initializer.expression, 'bindTypeRTGateway')) {
|
||||
this.fail(property, 'typertGateway must call bindTypeRTGateway()')
|
||||
}
|
||||
const call = property.initializer
|
||||
if (call.arguments.length < 2 || call.arguments.length > 3) {
|
||||
this.fail(call, 'bindTypeRTGateway() requires this, service key, and an optional options object')
|
||||
}
|
||||
if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) {
|
||||
this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this')
|
||||
}
|
||||
const serviceArgument = call.arguments[1]
|
||||
if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal')
|
||||
const service = stringLiteralValue(serviceArgument)
|
||||
if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal')
|
||||
let namespace = service
|
||||
const options = call.arguments[2]
|
||||
if (options !== undefined) {
|
||||
if (!ts.isObjectLiteralExpression(options)) {
|
||||
this.fail(options, 'bindTypeRTGateway() options must be an object literal')
|
||||
}
|
||||
for (const propertyOption of options.properties) {
|
||||
if (!ts.isPropertyAssignment(propertyOption)
|
||||
|| memberName(propertyOption.name) !== 'namespace') {
|
||||
this.fail(propertyOption, 'bindTypeRTGateway() only supports a namespace option')
|
||||
}
|
||||
const value = stringLiteralValue(propertyOption.initializer)
|
||||
if (value === undefined) this.fail(propertyOption.initializer, 'Gateway namespace must be a string literal')
|
||||
namespace = value
|
||||
}
|
||||
}
|
||||
if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"')
|
||||
if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"')
|
||||
return { service, namespace, site: property }
|
||||
}
|
||||
|
||||
private remoteMarker(
|
||||
member: ts.ClassElement,
|
||||
):
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
|
||||
| undefined {
|
||||
let found:
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
|
||||
| undefined
|
||||
for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
|
||||
const expression = decorator.expression
|
||||
let marker: typeof found
|
||||
if (this.isTypeMetaSymbol(expression, 'Remote')) {
|
||||
marker = { kind: 'direct' }
|
||||
} else if (ts.isCallExpression(expression)
|
||||
&& this.isTypeMetaSymbol(expression.expression, 'Remote')) {
|
||||
if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name')
|
||||
const exportName = stringLiteralValue(expression.arguments[0])
|
||||
if (exportName === undefined || !isRemoteSegment(exportName)) {
|
||||
this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"')
|
||||
}
|
||||
marker = { kind: 'direct', exportName }
|
||||
} else if (ts.isCallExpression(expression)
|
||||
&& this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) {
|
||||
if (expression.arguments.length < 1 || expression.arguments.length > 2) {
|
||||
this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name')
|
||||
}
|
||||
const context = stringLiteralValue(expression.arguments[0])
|
||||
if (context === undefined || !isRemoteSegment(context)) {
|
||||
this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"')
|
||||
}
|
||||
const exportArgument = expression.arguments[1]
|
||||
const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument)
|
||||
if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) {
|
||||
this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"')
|
||||
}
|
||||
marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } }
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if (found !== undefined) this.fail(decorator, 'a method can have only one Remote invocation decorator')
|
||||
found = marker
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode {
|
||||
const authored = this.requiredType(method, method.type, 'return')
|
||||
if (!ts.isTypeReferenceNode(authored)) return authored
|
||||
const symbol = this.checker.getSymbolAtLocation(authored.typeName)
|
||||
const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol)
|
||||
const resultType = authored.typeArguments?.[0]
|
||||
if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored
|
||||
return resultType
|
||||
}
|
||||
|
||||
private lookupDeclarations(): readonly StaticLookupDeclaration[] {
|
||||
if (this.staticLookups !== undefined) return this.staticLookups
|
||||
const byKey = new Map<string, StaticLookupDeclaration>()
|
||||
const byHost = new Map<SymbolId, StaticLookupDeclaration>()
|
||||
for (const declaration of this.typeMetaMapMembers('TypeRTLookupMap')) {
|
||||
if (!ts.isPropertySignature(declaration) || declaration.type === undefined) {
|
||||
this.fail(declaration, 'TypeRTLookupMap entries must be required properties')
|
||||
}
|
||||
const key = memberName(declaration.name)
|
||||
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"')
|
||||
if (!ts.isTypeReferenceNode(declaration.type)
|
||||
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup')
|
||||
|| declaration.type.typeArguments?.length !== 2) {
|
||||
this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup<Host, Wire>')
|
||||
}
|
||||
const hostType = declaration.type.typeArguments[0]
|
||||
const wireType = declaration.type.typeArguments[1]
|
||||
if (hostType === undefined || wireType === undefined) {
|
||||
this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup<Host, Wire>')
|
||||
}
|
||||
const host = this.symbolAtType(hostType)
|
||||
if (host === undefined) this.fail(hostType, 'TypeRTLookup Host must be a named type')
|
||||
const entry: StaticLookupDeclaration = {
|
||||
key,
|
||||
hostSymbol: this.symbolId(host),
|
||||
wireType,
|
||||
site: declaration,
|
||||
}
|
||||
if (byKey.has(key)) this.fail(declaration, `duplicate TypeRTLookupMap key ${key}`)
|
||||
if (byHost.has(entry.hostSymbol)) this.fail(declaration, `Host type ${host.name} has more than one TypeRT lookup`)
|
||||
byKey.set(key, entry)
|
||||
byHost.set(entry.hostSymbol, entry)
|
||||
}
|
||||
this.staticLookups = [...byKey.values()]
|
||||
return this.staticLookups
|
||||
}
|
||||
|
||||
private contextDeclarations(): ReadonlyMap<string, StaticContextDeclaration> {
|
||||
if (this.staticContexts !== undefined) return this.staticContexts
|
||||
const result = new Map<string, StaticContextDeclaration>()
|
||||
for (const declaration of this.typeMetaMapMembers('TypeRTContextMap')) {
|
||||
if (!ts.isPropertySignature(declaration) || declaration.type === undefined) {
|
||||
this.fail(declaration, 'TypeRTContextMap entries must be required properties')
|
||||
}
|
||||
const key = memberName(declaration.name)
|
||||
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"')
|
||||
if (!ts.isTypeReferenceNode(declaration.type)
|
||||
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext')
|
||||
|| declaration.type.typeArguments?.length !== 1) {
|
||||
this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext<Wire>')
|
||||
}
|
||||
if (result.has(key)) this.fail(declaration, `duplicate TypeRTContextMap key ${key}`)
|
||||
const wireType = declaration.type.typeArguments[0]
|
||||
if (wireType === undefined) this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext<Wire>')
|
||||
result.set(key, {
|
||||
key,
|
||||
wireType,
|
||||
site: declaration,
|
||||
})
|
||||
}
|
||||
this.staticContexts = result
|
||||
return result
|
||||
}
|
||||
|
||||
private typeMetaMapMembers(name: 'TypeRTLookupMap' | 'TypeRTContextMap'): ts.TypeElement[] {
|
||||
const result: ts.TypeElement[] = []
|
||||
for (const sourceFile of this.program.getSourceFiles()) {
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isModuleDeclaration(statement)
|
||||
|| !ts.isStringLiteral(statement.name)
|
||||
|| statement.name.text !== '@deepseek-ai/dsh-type-meta'
|
||||
|| statement.body === undefined
|
||||
|| !ts.isModuleBlock(statement.body)) continue
|
||||
for (const nested of statement.body.statements) {
|
||||
if (ts.isInterfaceDeclaration(nested) && nested.name.text === name) result.push(...nested.members)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private remoteBoundary(
|
||||
authoredType: ts.TypeNode,
|
||||
fallbackTypeSymbol: string,
|
||||
requireNamed: boolean,
|
||||
): RemoteBoundaryModel {
|
||||
const type = this.convertType(authoredType)
|
||||
const codecType = this.resolvedRemoteCodecType(authoredType)
|
||||
const rootSymbol = this.namedWorkspaceType(authoredType)
|
||||
if (rootSymbol !== undefined) {
|
||||
const imported = this.publicRemoteType(rootSymbol, authoredType)
|
||||
return {
|
||||
type,
|
||||
codecType,
|
||||
typeSymbol: `${imported.specifier}#${imported.name}`,
|
||||
imports: [imported],
|
||||
}
|
||||
}
|
||||
if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types')
|
||||
const imports = new Map<SymbolId, RemoteTypeImportModel>()
|
||||
const visit = (node: ts.Node): void => {
|
||||
if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) {
|
||||
const symbol = ts.isTypeReferenceNode(node)
|
||||
? this.checker.getSymbolAtLocation(node.typeName)
|
||||
: node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier)
|
||||
if (symbol !== undefined) {
|
||||
const resolved = this.resolveSymbol(symbol)
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration !== undefined
|
||||
&& !isStandardLibraryFile(declaration.getSourceFile().fileName)
|
||||
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) {
|
||||
const imported = this.publicRemoteType(resolved, node)
|
||||
imports.set(imported.symbol, imported)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(authoredType)
|
||||
return {
|
||||
type,
|
||||
codecType,
|
||||
typeSymbol: fallbackTypeSymbol,
|
||||
imports: [...imports.values()].sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one authored Remote boundary through the complete face Program.
|
||||
* Consumer declarations retain the authored alias, while codecs use this
|
||||
* concrete graph so declaration-merged mapped and conditional types are
|
||||
* validated without teaching the compiler-independent emitter TypeScript's
|
||||
* type evaluator.
|
||||
*/
|
||||
private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId {
|
||||
const completed = new Map<ts.Type, TypeNodeId>()
|
||||
const active = new Map<ts.Type, TypeNodeId>()
|
||||
const recursiveDeclarations = new Map<ts.Type, SymbolId>()
|
||||
const convert = (type: ts.Type): TypeNodeId => {
|
||||
const cached = completed.get(type)
|
||||
if (cached !== undefined) return cached
|
||||
const activeId = active.get(type)
|
||||
if (activeId !== undefined) {
|
||||
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
|
||||
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
||||
const elementId = element === undefined ? undefined : active.get(element)
|
||||
if (element !== undefined && elementId !== undefined) {
|
||||
return this.addNode(authoredType, {
|
||||
kind: 'array',
|
||||
element: this.resolvedCycleReference(
|
||||
element,
|
||||
authoredType,
|
||||
elementId,
|
||||
recursiveDeclarations,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
return this.resolvedCycleReference(type, authoredType, activeId, recursiveDeclarations)
|
||||
}
|
||||
const id = this.allocateNodeId(authoredType)
|
||||
active.set(type, id)
|
||||
try {
|
||||
const add = (model: TypeNodeInput): TypeNodeId => {
|
||||
this.nodes.set(id, { id, ...model })
|
||||
completed.set(type, id)
|
||||
return id
|
||||
}
|
||||
const flags = type.flags
|
||||
if ((flags & ts.TypeFlags.Any) !== 0) return add({ kind: 'keyword', name: 'any' })
|
||||
if ((flags & ts.TypeFlags.Unknown) !== 0) return add({ kind: 'keyword', name: 'unknown' })
|
||||
if ((flags & ts.TypeFlags.Never) !== 0) return add({ kind: 'keyword', name: 'never' })
|
||||
if ((flags & ts.TypeFlags.String) !== 0) return add({ kind: 'keyword', name: 'string' })
|
||||
if ((flags & ts.TypeFlags.Number) !== 0) return add({ kind: 'keyword', name: 'number' })
|
||||
if ((flags & ts.TypeFlags.BigInt) !== 0) return add({ kind: 'keyword', name: 'bigint' })
|
||||
if ((flags & ts.TypeFlags.Boolean) !== 0) return add({ kind: 'keyword', name: 'boolean' })
|
||||
if ((flags & ts.TypeFlags.ESSymbol) !== 0) return add({ kind: 'keyword', name: 'symbol' })
|
||||
if ((flags & ts.TypeFlags.Undefined) !== 0) return add({ kind: 'keyword', name: 'undefined' })
|
||||
if ((flags & ts.TypeFlags.Void) !== 0) return add({ kind: 'keyword', name: 'void' })
|
||||
if ((flags & ts.TypeFlags.Null) !== 0) return add({ kind: 'literal', value: null, text: 'null' })
|
||||
if ((flags & ts.TypeFlags.StringLiteral) !== 0) {
|
||||
const value = (type as ts.StringLiteralType).value
|
||||
return add({ kind: 'literal', value, text: JSON.stringify(value) })
|
||||
}
|
||||
if ((flags & ts.TypeFlags.NumberLiteral) !== 0) {
|
||||
const value = (type as ts.NumberLiteralType).value
|
||||
return add({ kind: 'literal', value, text: String(value) })
|
||||
}
|
||||
if ((flags & ts.TypeFlags.BigIntLiteral) !== 0) {
|
||||
const value = (type as ts.BigIntLiteralType).value
|
||||
const text = `${value.negative ? '-' : ''}${value.base10Value}n`
|
||||
return add({ kind: 'literal', value: BigInt(`${value.negative ? '-' : ''}${value.base10Value}`), text })
|
||||
}
|
||||
if ((flags & ts.TypeFlags.BooleanLiteral) !== 0) {
|
||||
const value = (type as ts.Type & { readonly intrinsicName?: string }).intrinsicName === 'true'
|
||||
return add({ kind: 'literal', value, text: String(value) })
|
||||
}
|
||||
if (type.isUnionOrIntersection()) {
|
||||
return add({
|
||||
kind: (flags & ts.TypeFlags.Union) !== 0 ? 'union' : 'intersection',
|
||||
types: type.types.map(convert),
|
||||
})
|
||||
}
|
||||
if ((flags & ts.TypeFlags.TypeParameter) !== 0) {
|
||||
this.fail(authoredType, 'Remote codec contains an unresolved type parameter')
|
||||
}
|
||||
if ((flags & ts.TypeFlags.Object) === 0) {
|
||||
this.fail(
|
||||
authoredType,
|
||||
`Remote codec type ${this.checker.typeToString(type, authoredType, ts.TypeFormatFlags.NoTruncation)} has no concrete Zod projection`,
|
||||
)
|
||||
}
|
||||
if (this.checker.isTupleType(type)) {
|
||||
const reference = type as ts.TypeReference
|
||||
const target = reference.target as ts.TupleType
|
||||
const arguments_ = this.checker.getTypeArguments(reference)
|
||||
return add({
|
||||
kind: 'tuple',
|
||||
elements: arguments_.map((argument, index) => {
|
||||
const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required
|
||||
return {
|
||||
type: convert(argument),
|
||||
optional: (elementFlags & ts.ElementFlags.Optional) !== 0,
|
||||
rest: (elementFlags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
|
||||
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
||||
if (element === undefined) this.fail(authoredType, 'Remote codec array has no element type')
|
||||
return add({ kind: 'array', element: convert(element) })
|
||||
}
|
||||
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) {
|
||||
this.fail(authoredType, 'Remote codec cannot contain callable or constructable values')
|
||||
}
|
||||
const members: MemberModel[] = []
|
||||
for (const property of this.checker.getPropertiesOfType(type)) {
|
||||
const declaration = property.valueDeclaration ?? property.declarations?.[0]
|
||||
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration ?? authoredType)
|
||||
const symbolKey = property.getName()
|
||||
members.push({
|
||||
...EMPTY_DOCUMENTATION,
|
||||
id: `${id}#${symbolKey}`,
|
||||
name: symbolKey,
|
||||
...(symbolKey.startsWith('__@') ? { computed: 'symbol' as const } : {}),
|
||||
optional: (property.flags & ts.SymbolFlags.Optional) !== 0,
|
||||
readonly: declaration !== undefined && hasModifier(declaration, ts.SyntaxKind.ReadonlyKeyword),
|
||||
async: false,
|
||||
abstract: false,
|
||||
static: false,
|
||||
visibility: 'public',
|
||||
location: this.location(authoredType),
|
||||
text: '',
|
||||
kind: 'property',
|
||||
type: convert(propertyType),
|
||||
})
|
||||
}
|
||||
for (const [index, info] of this.checker.getIndexInfosOfType(type).entries()) {
|
||||
members.push({
|
||||
...EMPTY_DOCUMENTATION,
|
||||
id: `${id}#index:${String(index)}`,
|
||||
name: '(index)',
|
||||
optional: false,
|
||||
readonly: info.isReadonly,
|
||||
async: false,
|
||||
abstract: false,
|
||||
static: false,
|
||||
visibility: 'public',
|
||||
location: this.location(authoredType),
|
||||
text: '',
|
||||
kind: 'index',
|
||||
signature: {
|
||||
typeParameters: [],
|
||||
parameters: [{
|
||||
name: 'key',
|
||||
binding: 'identifier',
|
||||
type: convert(info.keyType),
|
||||
optional: false,
|
||||
rest: false,
|
||||
receiver: false,
|
||||
}],
|
||||
returns: convert(info.type),
|
||||
},
|
||||
})
|
||||
}
|
||||
return add({ kind: 'object', members })
|
||||
} finally {
|
||||
active.delete(type)
|
||||
}
|
||||
}
|
||||
return convert(this.checker.getTypeFromTypeNode(authoredType))
|
||||
}
|
||||
|
||||
private resolvedCycleReference(
|
||||
type: ts.Type,
|
||||
site: ts.TypeNode,
|
||||
resolvedType: TypeNodeId,
|
||||
recursiveDeclarations: Map<ts.Type, SymbolId>,
|
||||
): TypeNodeId {
|
||||
const symbol = type.aliasSymbol ?? type.getSymbol()
|
||||
if (symbol === undefined) this.fail(site, 'Remote codec contains an unnamed recursive type')
|
||||
const resolved = this.resolveSymbol(symbol)
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined || isStandardLibraryFile(declaration.getSourceFile().fileName)) {
|
||||
this.fail(site, `Remote codec recursive type ${resolved.name} has no workspace declaration`)
|
||||
}
|
||||
const owner = this.registrationForFile(declaration.getSourceFile().fileName)
|
||||
if (owner === undefined) this.fail(site, `Remote codec recursive type ${resolved.name} is not owned by this face`)
|
||||
let id = recursiveDeclarations.get(type)
|
||||
if (id === undefined) {
|
||||
id = `${this.symbolId(resolved)}#remote-codec:${resolvedType}`
|
||||
recursiveDeclarations.set(type, id)
|
||||
this.declarations.set(id, {
|
||||
...EMPTY_DOCUMENTATION,
|
||||
id,
|
||||
package: owner.name,
|
||||
name: `${resolved.name}RemoteCodec`,
|
||||
kind: 'alias',
|
||||
abstract: false,
|
||||
exported: false,
|
||||
location: this.location(declaration),
|
||||
text: '',
|
||||
typeParameters: [],
|
||||
extends: [],
|
||||
implements: [],
|
||||
members: [],
|
||||
type: resolvedType,
|
||||
})
|
||||
}
|
||||
return this.addNode(site, {
|
||||
kind: 'reference',
|
||||
name: `${resolved.name}RemoteCodec`,
|
||||
target: { kind: 'declaration', symbol: id },
|
||||
arguments: [],
|
||||
})
|
||||
}
|
||||
|
||||
private namedWorkspaceType(node: ts.TypeNode): ts.Symbol | undefined {
|
||||
if (!ts.isTypeReferenceNode(node) && !ts.isImportTypeNode(node)) return undefined
|
||||
const symbol = ts.isTypeReferenceNode(node)
|
||||
? this.checker.getSymbolAtLocation(node.typeName)
|
||||
: node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier)
|
||||
if (symbol === undefined) return undefined
|
||||
const resolved = this.resolveSymbol(symbol)
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined
|
||||
|| isStandardLibraryFile(declaration.getSourceFile().fileName)
|
||||
|| this.registrationForFile(declaration.getSourceFile().fileName) === undefined) return undefined
|
||||
return resolved
|
||||
}
|
||||
|
||||
private publicRemoteType(symbol: ts.Symbol, site: ts.Node): RemoteTypeImportModel {
|
||||
const declaration = preferredDeclaration(symbol)
|
||||
if (declaration === undefined) this.fail(site, `type ${symbol.name} has no declaration`)
|
||||
const registration = this.registrationForFile(declaration.getSourceFile().fileName)
|
||||
if (registration === undefined) this.fail(site, `type ${symbol.name} is not owned by a workspace package`)
|
||||
const candidates: RemoteTypeImportModel[] = []
|
||||
for (const [subpath, target] of packageExportTargets(registration.manifest)) {
|
||||
if (subpath === '.' || subpath === './package.json' || subpath === './typert'
|
||||
|| subpath === './client/typert' || subpath === './remote' || target.includes('*')) continue
|
||||
const sourceFile = this.sourceFiles.get(realPath(sourcePathForExport(registration.root, target)))
|
||||
if (sourceFile === undefined) continue
|
||||
const moduleSymbol = this.checker.getSymbolAtLocation(sourceFile)
|
||||
if (moduleSymbol === undefined) continue
|
||||
for (const exported of this.checker.getExportsOfModule(moduleSymbol)) {
|
||||
if (this.resolveSymbol(exported) !== symbol) continue
|
||||
candidates.push({
|
||||
symbol: this.symbolId(symbol),
|
||||
specifier: packageExportSpecifier(registration.name, subpath),
|
||||
name: exported.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
const selected = candidates.sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))[0]
|
||||
if (selected === undefined) {
|
||||
this.fail(site, `Remote boundary type ${symbol.name} must be exported from a public non-root type subpath`)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
private isWorkspaceClass(symbol: ts.Symbol): boolean {
|
||||
const declaration = preferredDeclaration(symbol)
|
||||
return declaration !== undefined
|
||||
&& ts.isClassDeclaration(declaration)
|
||||
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined
|
||||
}
|
||||
|
||||
private isTypeMetaSymbol(node: ts.Node, name: string): boolean {
|
||||
const symbol = this.checker.getSymbolAtLocation(node)
|
||||
if (symbol === undefined) return false
|
||||
const resolved = this.resolveSymbol(symbol)
|
||||
if (resolved.name !== name) return false
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined) return false
|
||||
const registration = this.registrationForFile(declaration.getSourceFile().fileName)
|
||||
if (registration?.name === '@deepseek-ai/dsh-type-meta') return true
|
||||
for (let current: ts.Node | undefined = declaration; current !== undefined; current = optionalParent(current)) {
|
||||
if (ts.isModuleDeclaration(current)
|
||||
&& ts.isStringLiteral(current.name)
|
||||
&& current.name.text === '@deepseek-ai/dsh-type-meta') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private validateInvocationIdentity(packages: readonly PackageModel[]): void {
|
||||
const endpoints = new Map<string, InvocationModel>()
|
||||
const ids = new Map<string, InvocationModel>()
|
||||
for (const invocation of packages.flatMap(packageModel => packageModel.invocations)) {
|
||||
const endpoint = `${invocation.namespace}/${invocation.method}`
|
||||
const existingEndpoint = endpoints.get(endpoint)
|
||||
if (existingEndpoint !== undefined) {
|
||||
throw new TypertAnalysisError(
|
||||
`typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote endpoint ${endpoint} conflicts with ${existingEndpoint.id}`,
|
||||
)
|
||||
}
|
||||
const existingId = ids.get(invocation.id)
|
||||
if (existingId !== undefined) {
|
||||
throw new TypertAnalysisError(
|
||||
`typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote invocation id ${invocation.id} conflicts with ${existingId.id}`,
|
||||
)
|
||||
}
|
||||
endpoints.set(endpoint, invocation)
|
||||
ids.set(invocation.id, invocation)
|
||||
}
|
||||
}
|
||||
|
||||
private collectEvents(events: ts.InterfaceDeclaration): EventModel[] {
|
||||
const result: EventModel[] = []
|
||||
for (const member of events.members) {
|
||||
@@ -1015,6 +1777,11 @@ class FaceAnalyzer {
|
||||
): MemberModel[] {
|
||||
const result: MemberModel[] = []
|
||||
for (const member of members) {
|
||||
if (ts.isPropertyDeclaration(member)
|
||||
&& memberName(member.name) === 'typertGateway'
|
||||
&& member.initializer !== undefined
|
||||
&& ts.isCallExpression(member.initializer)
|
||||
&& this.isTypeMetaSymbol(member.initializer.expression, 'bindTypeRTGateway')) continue
|
||||
const visibility = visibilityOf(member)
|
||||
const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword)
|
||||
if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue
|
||||
@@ -1045,17 +1812,19 @@ class FaceAnalyzer {
|
||||
visibility: MemberVisibility,
|
||||
isStatic: boolean,
|
||||
): MemberBase {
|
||||
const name = member.name !== undefined
|
||||
? memberName(member.name)
|
||||
: ts.isCallSignatureDeclaration(member)
|
||||
? '(call)'
|
||||
: ts.isConstructSignatureDeclaration(member)
|
||||
? '(construct)'
|
||||
: '(index)'
|
||||
const identity = member.name !== undefined
|
||||
? this.memberIdentity(member.name)
|
||||
: {
|
||||
name: ts.isCallSignatureDeclaration(member)
|
||||
? '(call)'
|
||||
: ts.isConstructSignatureDeclaration(member)
|
||||
? '(construct)'
|
||||
: '(index)',
|
||||
}
|
||||
return {
|
||||
...documentationOf(member),
|
||||
id: `${ownerId}#${name}@${String(member.getStart())}`,
|
||||
name,
|
||||
id: `${ownerId}#${identity.name}@${String(member.getStart())}`,
|
||||
...identity,
|
||||
optional: 'questionToken' in member && member.questionToken !== undefined,
|
||||
readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword),
|
||||
async: hasModifier(member, ts.SyntaxKind.AsyncKeyword),
|
||||
@@ -1067,6 +1836,20 @@ class FaceAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
private memberIdentity(name: ts.PropertyName): Pick<MemberBase, 'name' | 'jsonName' | 'computed'> {
|
||||
if (!ts.isComputedPropertyName(name)) return { name: memberName(name) }
|
||||
const expression = name.expression
|
||||
if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression)
|
||||
|| ts.isNoSubstitutionTemplateLiteral(expression)) {
|
||||
return { name: memberName(name), jsonName: expression.text }
|
||||
}
|
||||
const type = this.checker.getTypeAtLocation(expression)
|
||||
return {
|
||||
name: memberName(name),
|
||||
computed: (type.flags & ts.TypeFlags.UniqueESSymbol) !== 0 ? 'symbol' : 'dynamic',
|
||||
}
|
||||
}
|
||||
|
||||
private signature(
|
||||
node: ts.SignatureDeclarationBase,
|
||||
explicitReturn: ts.TypeNode | undefined,
|
||||
@@ -1570,7 +2353,23 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean {
|
||||
|| ts.isInterfaceDeclaration(statement)
|
||||
|| ts.isTypeAliasDeclaration(statement)
|
||||
|| ts.isEnumDeclaration(statement))
|
||||
&& typertMode(statement) !== undefined) return true
|
||||
&& (typertMode(statement) !== undefined || typertServiceTag(statement) !== undefined)) return true
|
||||
if (ts.isClassDeclaration(statement)) {
|
||||
for (const member of statement.members) {
|
||||
if (ts.isPropertyDeclaration(member)
|
||||
&& memberName(member.name) === 'typertGateway'
|
||||
&& member.initializer !== undefined
|
||||
&& ts.isCallExpression(member.initializer)
|
||||
&& expressionName(member.initializer.expression) === 'bindTypeRTGateway') return true
|
||||
for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
|
||||
const expression = ts.isCallExpression(decorator.expression)
|
||||
? decorator.expression.expression
|
||||
: decorator.expression
|
||||
const name = expressionName(expression)
|
||||
if (name === 'Remote' || name === 'RemoteContext') return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ts.isModuleDeclaration(statement)
|
||||
|| !ts.isStringLiteral(statement.name)
|
||||
|| statement.name.text !== 'cordis'
|
||||
@@ -1588,6 +2387,7 @@ function hasPackageSurface(model: PackageModel): boolean {
|
||||
|| model.events.length > 0
|
||||
|| model.objects.length > 0
|
||||
|| model.schemas.length > 0
|
||||
|| model.invocations.length > 0
|
||||
}
|
||||
|
||||
function isDualFacePackage(manifest: Record<string, unknown>): boolean {
|
||||
@@ -1599,7 +2399,9 @@ function isDualFacePackage(manifest: Record<string, unknown>): boolean {
|
||||
function hostExportSubpaths(manifest: Record<string, unknown>): string[] {
|
||||
return packageExportTargets(manifest)
|
||||
.map(([subpath]) => subpath)
|
||||
.filter(subpath => subpath !== './client' && !subpath.startsWith('./client/'))
|
||||
.filter(subpath => subpath !== './client'
|
||||
&& !subpath.startsWith('./client/')
|
||||
&& subpath !== './remote')
|
||||
}
|
||||
|
||||
function clientExportSubpaths(manifest: Record<string, unknown>): string[] {
|
||||
@@ -1668,6 +2470,10 @@ function preferredDeclaration(symbol: ts.Symbol): ts.Declaration | undefined {
|
||||
?? symbol.declarations?.[0]
|
||||
}
|
||||
|
||||
function optionalParent(node: ts.Node): ts.Node | undefined {
|
||||
return (node as ts.Node & { readonly parent?: ts.Node }).parent
|
||||
}
|
||||
|
||||
function isTypeDeclaration(
|
||||
node: ts.Node,
|
||||
): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration {
|
||||
@@ -1822,6 +2628,11 @@ function typertMode(node: ts.Node): 'object' | 'schema' | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function typertServiceTag(node: ts.Node): ts.JSDocTag | undefined {
|
||||
return ts.getJSDocTags(node).find(tag => tag.tagName.text === 'typert'
|
||||
&& (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/, 1)[0] === 'service')
|
||||
}
|
||||
|
||||
function memberName(name: ts.PropertyName | ts.BindingName): string {
|
||||
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name)
|
||||
|| ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text
|
||||
@@ -1829,6 +2640,26 @@ function memberName(name: ts.PropertyName | ts.BindingName): string {
|
||||
return name.getText()
|
||||
}
|
||||
|
||||
function stringLiteralValue(node: ts.Node | undefined): string | undefined {
|
||||
return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
||||
? node.text
|
||||
: undefined
|
||||
}
|
||||
|
||||
function isRemoteSegment(value: string): boolean {
|
||||
return value.length > 0 && !value.includes('/')
|
||||
}
|
||||
|
||||
function expressionName(node: ts.Expression): string | undefined {
|
||||
if (ts.isIdentifier(node)) return node.text
|
||||
if (ts.isPropertyAccessExpression(node)) return node.name.text
|
||||
return undefined
|
||||
}
|
||||
|
||||
function packageExportSpecifier(packageName: string, subpath: string): string {
|
||||
return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
|
||||
}
|
||||
|
||||
function visibilityOf(node: ts.Node): MemberVisibility {
|
||||
if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private'
|
||||
if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private'
|
||||
|
||||
@@ -231,7 +231,7 @@ export class CordisCatalogProjector {
|
||||
for (const service of packageModel.services) {
|
||||
const declaration = this.renderer.declaration(service.symbol)
|
||||
if (declaration.kind !== 'class'
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/index\.ts$/.test(service.location.file)
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file)
|
||||
|| declaration.location.file !== service.location.file) continue
|
||||
const doc = parseJsDoc(declaration.jsDoc ?? '').doc
|
||||
const source = pointer(declaration.location)
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
* @module @deepseek-ai/dsh-typert-generator/emitter
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { posix } from 'node:path'
|
||||
import { GenMapping, addMapping, toEncodedMap } from '@jridgewell/gen-mapping'
|
||||
import type {
|
||||
DocumentationModel,
|
||||
FaceModel,
|
||||
InvocationModel,
|
||||
MemberModel,
|
||||
PackageModel,
|
||||
RemoteBoundaryModel,
|
||||
RemoteTypeImportModel,
|
||||
SchemaModel,
|
||||
SymbolId,
|
||||
TypeDeclarationModel,
|
||||
@@ -29,6 +35,14 @@ export interface ModelEmitResult {
|
||||
readonly exports: readonly string[]
|
||||
readonly js: string
|
||||
readonly dts: string
|
||||
readonly remote?: RemoteModelEmitResult
|
||||
}
|
||||
|
||||
/** Host-for-Client Remote contribution generated from the Host Program. */
|
||||
export interface RemoteModelEmitResult {
|
||||
readonly js: string
|
||||
readonly dts: string
|
||||
readonly dtsMap: string
|
||||
}
|
||||
|
||||
interface RuntimeMemberModel {
|
||||
@@ -92,7 +106,11 @@ export class FaceModelEmitter {
|
||||
if (packageModel === undefined) {
|
||||
throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`)
|
||||
}
|
||||
const schemas = new SchemaEmitter(this.renderer, packageModel.schemas)
|
||||
const schemas = new SchemaEmitter(
|
||||
this.renderer,
|
||||
packageModel.schemas,
|
||||
invocationBoundaryRoots(packageModel.invocations),
|
||||
)
|
||||
const schemaArtifact = schemas.emit()
|
||||
const runtimeModel = this.runtimeModel(packageModel)
|
||||
const js = this.renderJs(packageModel, schemaArtifact, runtimeModel)
|
||||
@@ -103,6 +121,9 @@ export class FaceModelEmitter {
|
||||
exports: packageModel.schemas.map(schema => schema.export.name),
|
||||
js,
|
||||
dts,
|
||||
...(this.face.face === 'host' && packageModel.invocations.length > 0
|
||||
? { remote: this.emitRemote(packageModel) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +205,11 @@ export class FaceModelEmitter {
|
||||
lines.push(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`)
|
||||
}
|
||||
lines.push(' ],')
|
||||
lines.push(' invocations: [')
|
||||
for (const invocation of packageModel.invocations) {
|
||||
lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`)
|
||||
}
|
||||
lines.push(' ],')
|
||||
lines.push(` model: ${indent(model, 2).trimStart()},`)
|
||||
lines.push('}')
|
||||
return `${lines.join('\n')}\n`
|
||||
@@ -215,6 +241,246 @@ export class FaceModelEmitter {
|
||||
lines.push('export declare const TYPERT: unknown')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
private emitRemote(packageModel: PackageModel): RemoteModelEmitResult {
|
||||
const schemas = new SchemaEmitter(
|
||||
this.renderer,
|
||||
[],
|
||||
invocationBoundaryRoots(packageModel.invocations),
|
||||
).emit()
|
||||
const lines = [
|
||||
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
|
||||
]
|
||||
if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '')
|
||||
lines.push(...schemas.definitions)
|
||||
if (schemas.definitions.length > 0) lines.push('')
|
||||
lines.push('export const TYPERT_REMOTE = {')
|
||||
lines.push(` package: ${quote(packageModel.name)},`)
|
||||
lines.push(' descriptors: [')
|
||||
for (const invocation of packageModel.invocations) {
|
||||
lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`)
|
||||
}
|
||||
lines.push(' ],')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push('export default TYPERT_REMOTE')
|
||||
const declaration = this.renderRemoteDts(packageModel)
|
||||
return {
|
||||
js: `${lines.join('\n')}\n`,
|
||||
...declaration,
|
||||
}
|
||||
}
|
||||
|
||||
private invocationLiteral(invocation: InvocationModel, schemas: SchemaArtifact): string {
|
||||
const lines = [
|
||||
'{',
|
||||
` id: ${quote(invocation.id)},`,
|
||||
` service: ${quote(invocation.service)},`,
|
||||
` namespace: ${quote(invocation.namespace)},`,
|
||||
` method: ${quote(invocation.method)},`,
|
||||
]
|
||||
if (invocation.implementation !== undefined) {
|
||||
lines.push(` implementation: ${quote(invocation.implementation)},`)
|
||||
}
|
||||
if (invocation.invocation.kind === 'direct') {
|
||||
lines.push(' invocation: { kind: \'direct\' },')
|
||||
} else {
|
||||
lines.push(' invocation: {')
|
||||
lines.push(' kind: \'context\',')
|
||||
lines.push(` context: ${quote(invocation.invocation.context)},`)
|
||||
lines.push(` wire: ${quote(invocation.invocation.wire)},`)
|
||||
lines.push(` codec: ${indent(strictCodec(
|
||||
invocation.invocation.boundary,
|
||||
schemas.boundary(contextBoundaryKey(invocation)),
|
||||
), 4).trimStart()},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
if (invocation.scope !== undefined) {
|
||||
lines.push(' scope: {')
|
||||
lines.push(` context: ${quote(invocation.scope.context)},`)
|
||||
lines.push(` wire: ${quote(invocation.scope.wire)},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(' parameters: [')
|
||||
invocation.parameters.forEach((parameter, index) => {
|
||||
lines.push(' {')
|
||||
lines.push(` name: ${quote(parameter.name)},`)
|
||||
lines.push(` wire: ${quote(parameter.wire)},`)
|
||||
lines.push(` source: ${quote(parameter.source)},`)
|
||||
if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`)
|
||||
lines.push(` codec: ${indent(strictCodec(
|
||||
parameter.boundary,
|
||||
schemas.boundary(parameterBoundaryKey(invocation, index)),
|
||||
), 6).trimStart()},`)
|
||||
lines.push(' },')
|
||||
})
|
||||
lines.push(' ],')
|
||||
lines.push(` result: ${indent(strictCodec(
|
||||
invocation.result,
|
||||
schemas.boundary(resultBoundaryKey(invocation)),
|
||||
), 2).trimStart()},`)
|
||||
lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`)
|
||||
lines.push('}')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
private renderRemoteDts(packageModel: PackageModel): Pick<RemoteModelEmitResult, 'dts' | 'dtsMap'> {
|
||||
const imports = remoteImports(packageModel.invocations)
|
||||
const referenceNames = allocateRemoteImportNames(imports)
|
||||
const grouped = new Map<string, { readonly name: string; readonly local: string }[]>()
|
||||
for (const imported of imports) {
|
||||
const values = grouped.get(imported.specifier) ?? []
|
||||
values.push({
|
||||
name: imported.name,
|
||||
local: referenceNames.get(imported.symbol) as string,
|
||||
})
|
||||
grouped.set(imported.specifier, values)
|
||||
}
|
||||
const lines = [
|
||||
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
|
||||
'import type {',
|
||||
' TypeRTRemoteContribution,',
|
||||
'} from \'@deepseek-ai/dsh-type-meta\'',
|
||||
]
|
||||
const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' })
|
||||
for (const [specifier, values] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const names = values.sort((left, right) => left.local.localeCompare(right.local)).map(value =>
|
||||
value.name === value.local ? value.name : `${value.name} as ${value.local}`)
|
||||
lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('declare module \'@deepseek-ai/dsh-type-meta\' {')
|
||||
const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct')
|
||||
const scoped = packageModel.invocations.filter(invocation =>
|
||||
invocation.invocation.kind === 'context' || invocation.scope !== undefined)
|
||||
if (direct.length > 0) {
|
||||
for (const namespace of uniqueNamespaces(direct)) {
|
||||
lines.push(` interface ${remoteNamespaceInterface(namespace)} {`)
|
||||
for (const invocation of direct.filter(candidate => candidate.namespace === namespace)) {
|
||||
this.pushRemoteNamespaceSignature(lines, sourceMap, packageModel, invocation, referenceNames)
|
||||
}
|
||||
lines.push(' }')
|
||||
}
|
||||
lines.push(' interface TypeRTRemoteMap {')
|
||||
for (const invocation of direct) {
|
||||
this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, false)
|
||||
}
|
||||
lines.push(' }')
|
||||
lines.push(' interface TypeRTRemoteNamespaceMap {')
|
||||
for (const namespace of uniqueNamespaces(direct)) {
|
||||
lines.push(` ${quote(namespace)}: ${remoteNamespaceInterface(namespace)}`)
|
||||
}
|
||||
lines.push(' }')
|
||||
}
|
||||
if (scoped.length > 0) {
|
||||
lines.push(' interface TypeRTRemoteContextMap {')
|
||||
for (const invocation of scoped) {
|
||||
this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true)
|
||||
}
|
||||
lines.push(' }')
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push('export declare const TYPERT_REMOTE: TypeRTRemoteContribution')
|
||||
lines.push('export default TYPERT_REMOTE')
|
||||
lines.push('//# sourceMappingURL=typert.remote-client.d.ts.map')
|
||||
return {
|
||||
dts: `${lines.join('\n')}\n`,
|
||||
dtsMap: `${JSON.stringify(toEncodedMap(sourceMap))}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
private pushRemoteSignature(
|
||||
lines: string[],
|
||||
sourceMap: GenMapping,
|
||||
packageModel: PackageModel,
|
||||
invocation: InvocationModel,
|
||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||
scoped: boolean,
|
||||
): void {
|
||||
const signature = this.remoteSignature(invocation, referenceNames, scoped)
|
||||
const line = ` ${signature}`
|
||||
lines.push(line)
|
||||
const generatedLine = lines.length
|
||||
const keyLength = signature.indexOf(': (')
|
||||
if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`)
|
||||
const source = remoteDeclarationSource(packageModel, invocation)
|
||||
addMapping(sourceMap, {
|
||||
generated: { line: generatedLine, column: 4 },
|
||||
source,
|
||||
original: { line: invocation.location.line, column: invocation.location.column - 1 },
|
||||
name: invocation.method,
|
||||
})
|
||||
addMapping(sourceMap, {
|
||||
generated: { line: generatedLine, column: 4 + keyLength },
|
||||
})
|
||||
}
|
||||
|
||||
private pushRemoteNamespaceSignature(
|
||||
lines: string[],
|
||||
sourceMap: GenMapping,
|
||||
packageModel: PackageModel,
|
||||
invocation: InvocationModel,
|
||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||
): void {
|
||||
const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}`
|
||||
lines.push(` ${signature}`)
|
||||
const generatedLine = lines.length
|
||||
const source = remoteDeclarationSource(packageModel, invocation)
|
||||
addMapping(sourceMap, {
|
||||
generated: { line: generatedLine, column: 4 },
|
||||
source,
|
||||
original: { line: invocation.location.line, column: invocation.location.column - 1 },
|
||||
name: invocation.method,
|
||||
})
|
||||
addMapping(sourceMap, {
|
||||
generated: { line: generatedLine, column: 4 + invocation.method.length },
|
||||
})
|
||||
}
|
||||
|
||||
private remoteSignature(
|
||||
invocation: InvocationModel,
|
||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||
scoped: boolean,
|
||||
): string {
|
||||
const context = invocation.invocation.kind === 'context'
|
||||
? invocation.invocation.context
|
||||
: invocation.scope?.context
|
||||
const key = scoped
|
||||
? `${context as string}:${invocation.namespace}/${invocation.method}`
|
||||
: `${invocation.namespace}/${invocation.method}`
|
||||
return `${quote(key)}: ${this.remoteFunctionType(invocation, referenceNames, scoped)}`
|
||||
}
|
||||
|
||||
private remoteFunctionType(
|
||||
invocation: InvocationModel,
|
||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||
scoped: boolean,
|
||||
): string {
|
||||
const parameters = invocation.parameters.filter(parameter =>
|
||||
!scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter =>
|
||||
`${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
|
||||
const result = this.renderer.renderType(invocation.result.type, referenceNames)
|
||||
return `(${parameters.join(', ')}) => Promise<${result}>`
|
||||
}
|
||||
}
|
||||
|
||||
function remoteDeclarationSource(packageModel: PackageModel, invocation: InvocationModel): string {
|
||||
const relativeSource = posix.relative(packageModel.root, invocation.location.file)
|
||||
if (relativeSource === '' || relativeSource === '..' || relativeSource.startsWith('../') || posix.isAbsolute(relativeSource)) {
|
||||
throw new TypertEmitError(
|
||||
`Remote declaration ${invocation.id} is outside its package root ${packageModel.root}`,
|
||||
)
|
||||
}
|
||||
return posix.join('..', relativeSource)
|
||||
}
|
||||
|
||||
function uniqueNamespaces(invocations: readonly InvocationModel[]): string[] {
|
||||
return [...new Set(invocations.map(invocation => invocation.namespace))].sort()
|
||||
}
|
||||
|
||||
function remoteNamespaceInterface(namespace: string): string {
|
||||
return `TypeRTRemoteNamespace$${Buffer.from(namespace, 'utf8').toString('hex')}`
|
||||
}
|
||||
|
||||
interface SchemaExport {
|
||||
@@ -226,15 +492,23 @@ interface SchemaExport {
|
||||
interface SchemaArtifact {
|
||||
readonly definitions: readonly string[]
|
||||
readonly exports: readonly SchemaExport[]
|
||||
boundary(key: string): string
|
||||
}
|
||||
|
||||
interface BoundarySchemaRoot {
|
||||
readonly key: string
|
||||
readonly type: TypeNodeId
|
||||
}
|
||||
|
||||
class SchemaEmitter {
|
||||
private readonly names = new Map<SymbolId, string>()
|
||||
private readonly boundaryNames = new Map<string, string>()
|
||||
private readonly declarations: TypeDeclarationModel[]
|
||||
|
||||
constructor(
|
||||
private readonly renderer: TypeGraphRenderer,
|
||||
private readonly schemas: readonly SchemaModel[],
|
||||
private readonly boundaries: readonly BoundarySchemaRoot[],
|
||||
) {
|
||||
const declarations = new Map<SymbolId, TypeDeclarationModel>()
|
||||
for (const schema of schemas) {
|
||||
@@ -242,6 +516,11 @@ class SchemaEmitter {
|
||||
declarations.set(declaration.id, declaration)
|
||||
}
|
||||
}
|
||||
for (const boundary of boundaries) {
|
||||
for (const declaration of renderer.declarationClosureForTypes([boundary.type])) {
|
||||
declarations.set(declaration.id, declaration)
|
||||
}
|
||||
}
|
||||
this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id))
|
||||
const identifiers = new Set<string>()
|
||||
for (const declaration of this.declarations) {
|
||||
@@ -252,65 +531,92 @@ class SchemaEmitter {
|
||||
identifiers.add(name)
|
||||
this.names.set(declaration.id, name)
|
||||
}
|
||||
for (const boundary of boundaries) {
|
||||
const base = `${safeIdentifier(boundary.key)}$schema`
|
||||
let name = base
|
||||
let suffix = 2
|
||||
while (identifiers.has(name)) name = `${base}${String(suffix++)}`
|
||||
identifiers.add(name)
|
||||
this.boundaryNames.set(boundary.key, name)
|
||||
}
|
||||
}
|
||||
|
||||
emit(): SchemaArtifact {
|
||||
const definitions = this.declarations.map((declaration) => {
|
||||
if (declaration.typeParameters.length > 0) {
|
||||
this.fail(declaration.name, 'generic declarations require a schema-factory projection')
|
||||
}
|
||||
return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}`
|
||||
})
|
||||
const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration))
|
||||
for (const boundary of this.boundaries) {
|
||||
definitions.push(`const ${this.boundaryName(boundary.key)} = ${this.typeSchema(boundary.type)}`)
|
||||
}
|
||||
const exports = this.schemas.map((model): SchemaExport => ({
|
||||
model,
|
||||
exportName: safeIdentifier(model.export.name),
|
||||
internalName: this.schemaName(model.symbol),
|
||||
internalName: this.exportSchemaName(model),
|
||||
}))
|
||||
return { definitions, exports }
|
||||
return {
|
||||
definitions,
|
||||
exports,
|
||||
boundary: key => this.boundaryName(key),
|
||||
}
|
||||
}
|
||||
|
||||
private declarationSchema(declaration: TypeDeclarationModel): string {
|
||||
private declarationDefinition(declaration: TypeDeclarationModel): string {
|
||||
const name = this.schemaName(declaration.id)
|
||||
if (declaration.typeParameters.length === 0) {
|
||||
return `const ${name} = ${this.declarationSchema(declaration, new Map())}`
|
||||
}
|
||||
const parameters = declaration.typeParameters.map((parameter, index) =>
|
||||
[`type${String(index)}$schema`, parameter.id] as const)
|
||||
const substitutions = new Map(parameters.map(([schema, id]) => [id, schema]))
|
||||
return `const ${name} = (${parameters.map(([schema]) => schema).join(', ')}) => ${this.declarationSchema(declaration, substitutions)}`
|
||||
}
|
||||
|
||||
private declarationSchema(
|
||||
declaration: TypeDeclarationModel,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
if (declaration.kind === 'enum') {
|
||||
this.fail(declaration.name, 'enum declarations have no Zod projection')
|
||||
}
|
||||
if (declaration.kind === 'alias') {
|
||||
if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type')
|
||||
return this.describe(this.typeSchema(declaration.type), declaration)
|
||||
return this.describe(this.typeSchema(declaration.type, substitutions), declaration)
|
||||
}
|
||||
const own = this.objectSchema(declaration.members, declaration.name)
|
||||
const own = this.objectSchema(declaration.members, declaration.name, substitutions)
|
||||
let result = own
|
||||
for (const heritage of declaration.extends) {
|
||||
result = `z.intersection(${this.typeSchema(heritage)}, ${result})`
|
||||
result = `z.intersection(${this.typeSchema(heritage, substitutions)}, ${result})`
|
||||
}
|
||||
return this.describe(result, declaration)
|
||||
}
|
||||
|
||||
private typeSchema(id: TypeNodeId): string {
|
||||
private typeSchema(id: TypeNodeId, substitutions: ReadonlyMap<string, string> = new Map()): string {
|
||||
const node = this.renderer.node(id)
|
||||
switch (node.kind) {
|
||||
case 'keyword': return this.keywordSchema(node.name)
|
||||
case 'literal': return `z.literal(${node.text})`
|
||||
case 'parenthesized': return this.typeSchema(node.type)
|
||||
case 'reference': return this.referenceSchema(node)
|
||||
case 'parenthesized': return this.typeSchema(node.type, substitutions)
|
||||
case 'reference': return this.referenceSchema(node, substitutions)
|
||||
case 'union': {
|
||||
if (node.types.length === 0) return 'z.never()'
|
||||
if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId)
|
||||
return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])`
|
||||
if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId, substitutions)
|
||||
return `z.union([${node.types.map(type => this.typeSchema(type, substitutions)).join(', ')}])`
|
||||
}
|
||||
case 'intersection': {
|
||||
const [head, ...tail] = node.types
|
||||
if (head === undefined) return 'z.unknown()'
|
||||
return tail.reduce((left, right) => `z.intersection(${left}, ${this.typeSchema(right)})`, this.typeSchema(head))
|
||||
return tail.reduce(
|
||||
(left, right) => `z.intersection(${left}, ${this.typeSchema(right, substitutions)})`,
|
||||
this.typeSchema(head, substitutions),
|
||||
)
|
||||
}
|
||||
case 'array': return `z.array(${this.typeSchema(node.element)})`
|
||||
case 'array': return `z.array(${this.typeSchema(node.element, substitutions)})`
|
||||
case 'tuple': {
|
||||
const fixed = node.elements.filter(element => !element.rest)
|
||||
const rest = node.elements.find(element => element.rest)
|
||||
let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type), element.optional)).join(', ')}])`
|
||||
if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type)})`
|
||||
let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type, substitutions), element.optional)).join(', ')}])`
|
||||
if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type, substitutions)})`
|
||||
return schema
|
||||
}
|
||||
case 'object': return this.objectSchema(node.members, id)
|
||||
case 'object': return this.objectSchema(node.members, id, substitutions)
|
||||
case 'operator':
|
||||
case 'indexed-access':
|
||||
case 'conditional':
|
||||
@@ -326,9 +632,27 @@ class SchemaEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private referenceSchema(node: Extract<TypeNodeModel, { kind: 'reference' }>): string {
|
||||
private referenceSchema(
|
||||
node: Extract<TypeNodeModel, { kind: 'reference' }>,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
if (node.target.kind === 'declaration') {
|
||||
return `z.lazy(() => ${this.schemaName(node.target.symbol)})`
|
||||
const name = this.schemaName(node.target.symbol)
|
||||
const declaration = this.renderer.declaration(node.target.symbol)
|
||||
if (declaration.typeParameters.length === 0) {
|
||||
if (node.arguments.length > 0) {
|
||||
this.fail(node.name, `non-generic declaration received ${String(node.arguments.length)} type arguments`)
|
||||
}
|
||||
return `z.lazy(() => ${name})`
|
||||
}
|
||||
const arguments_ = this.declarationArguments(node, declaration, substitutions)
|
||||
return `z.lazy(() => ${name}(${arguments_.join(', ')}))`
|
||||
}
|
||||
if (node.target.kind === 'type-parameter') {
|
||||
if (node.arguments.length > 0) this.fail(node.name, 'type parameter reference cannot receive type arguments')
|
||||
const schema = substitutions.get(node.target.parameter)
|
||||
if (schema === undefined) this.fail(node.name, 'type parameter has no schema substitution')
|
||||
return schema
|
||||
}
|
||||
if (node.target.kind === 'standard') {
|
||||
switch (node.target.name) {
|
||||
@@ -336,13 +660,16 @@ class SchemaEmitter {
|
||||
case 'ReadonlyArray': {
|
||||
const element = node.arguments[0]
|
||||
if (element === undefined) this.fail(node.name, 'array reference has no element type')
|
||||
return this.readonly(`z.array(${this.typeSchema(element)})`, node.target.name === 'ReadonlyArray')
|
||||
return this.readonly(
|
||||
`z.array(${this.typeSchema(element, substitutions)})`,
|
||||
node.target.name === 'ReadonlyArray',
|
||||
)
|
||||
}
|
||||
case 'Record': {
|
||||
const key = node.arguments[0]
|
||||
const value = node.arguments[1]
|
||||
if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types')
|
||||
return `z.record(${this.typeSchema(key)}, ${this.typeSchema(value)})`
|
||||
return `z.record(${this.typeSchema(key, substitutions)}, ${this.typeSchema(value, substitutions)})`
|
||||
}
|
||||
case 'Date': return 'z.date()'
|
||||
default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`)
|
||||
@@ -351,31 +678,97 @@ class SchemaEmitter {
|
||||
this.fail(node.name, `${node.target.kind} reference has no Zod projection`)
|
||||
}
|
||||
|
||||
private tupleRestSchema(id: TypeNodeId): string {
|
||||
private declarationArguments(
|
||||
node: Extract<TypeNodeModel, { kind: 'reference' }>,
|
||||
declaration: TypeDeclarationModel,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
if (node.arguments.length > declaration.typeParameters.length) {
|
||||
this.fail(
|
||||
node.name,
|
||||
`generic declaration accepts ${String(declaration.typeParameters.length)} type arguments but received ${String(node.arguments.length)}`,
|
||||
)
|
||||
}
|
||||
const resolved = new Map(substitutions)
|
||||
const arguments_: string[] = []
|
||||
for (const [index, parameter] of declaration.typeParameters.entries()) {
|
||||
const argument = node.arguments[index]
|
||||
const schema = argument === undefined
|
||||
? parameter.default === undefined
|
||||
? this.fail(node.name, `missing type argument ${parameter.name}`)
|
||||
: this.typeSchema(parameter.default, resolved)
|
||||
: this.typeSchema(argument, substitutions)
|
||||
arguments_.push(schema)
|
||||
resolved.set(parameter.id, schema)
|
||||
}
|
||||
return arguments_
|
||||
}
|
||||
|
||||
private tupleRestSchema(id: TypeNodeId, substitutions: ReadonlyMap<string, string>): string {
|
||||
const node = this.renderer.node(id)
|
||||
if (node.kind === 'array') return this.typeSchema(node.element)
|
||||
if (node.kind === 'array') return this.typeSchema(node.element, substitutions)
|
||||
if (node.kind === 'reference'
|
||||
&& node.target.kind === 'standard'
|
||||
&& (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) {
|
||||
const element = node.arguments[0]
|
||||
if (element === undefined) this.fail(node.name, 'tuple rest array has no element type')
|
||||
return this.typeSchema(element)
|
||||
return this.typeSchema(element, substitutions)
|
||||
}
|
||||
this.fail(id, 'tuple rest element must retain an array type')
|
||||
}
|
||||
|
||||
private objectSchema(members: readonly MemberModel[], subject: string): string {
|
||||
private objectSchema(
|
||||
members: readonly MemberModel[],
|
||||
subject: string,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const properties: string[] = []
|
||||
const indices: string[] = []
|
||||
let symbolMembers = 0
|
||||
for (const member of members) {
|
||||
if (member.static || member.visibility !== 'public') continue
|
||||
if (member.computed === 'symbol') {
|
||||
symbolMembers++
|
||||
continue
|
||||
}
|
||||
if (member.computed === 'dynamic') {
|
||||
this.fail(subject, `computed member ${member.name} has no fixed JSON property name`)
|
||||
}
|
||||
if (member.kind === 'index') {
|
||||
const parameter = member.signature.parameters[0]
|
||||
if (member.signature.parameters.length !== 1 || parameter === undefined) {
|
||||
this.fail(subject, 'index signature must have exactly one key parameter')
|
||||
}
|
||||
indices.push(this.readonly(
|
||||
`z.record(${this.typeSchema(parameter.type, substitutions)}, ${this.typeSchema(member.signature.returns, substitutions)})`,
|
||||
member.readonly,
|
||||
))
|
||||
continue
|
||||
}
|
||||
if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`)
|
||||
const property = this.describe(
|
||||
this.optional(this.readonly(this.typeSchema(member.type), member.readonly), member.optional),
|
||||
this.optional(this.readonly(this.typeSchema(member.type, substitutions), member.readonly), member.optional),
|
||||
member,
|
||||
)
|
||||
properties.push(`${quote(member.name)}: ${property}`)
|
||||
properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`)
|
||||
}
|
||||
return `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
|
||||
if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature')
|
||||
// A unique-symbol-only object is a compile-time marker and imposes no JSON shape.
|
||||
if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()'
|
||||
const object = `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
|
||||
const index = indices[0]
|
||||
if (index === undefined) return object
|
||||
if (properties.length === 0) return index
|
||||
return `z.intersection(${object}, ${index})`
|
||||
}
|
||||
|
||||
private exportSchemaName(model: SchemaModel): string {
|
||||
const name = this.schemaName(model.symbol)
|
||||
const declaration = this.renderer.declaration(model.symbol)
|
||||
if (declaration.typeParameters.length > 0) {
|
||||
this.fail(model.export.name, 'generic schema exports require a concrete declaration')
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
private keywordSchema(name: string): string {
|
||||
@@ -401,6 +794,12 @@ class SchemaEmitter {
|
||||
return name
|
||||
}
|
||||
|
||||
private boundaryName(key: string): string {
|
||||
const name = this.boundaryNames.get(key)
|
||||
if (name === undefined) this.fail(key, 'invocation boundary is outside the selected schema roots')
|
||||
return name
|
||||
}
|
||||
|
||||
private describe(schema: string, documentation: DocumentationModel): string {
|
||||
return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})`
|
||||
}
|
||||
@@ -431,6 +830,77 @@ function documentationLiteral(documentation: DocumentationModel): DocumentationM
|
||||
}
|
||||
}
|
||||
|
||||
function invocationBoundaryRoots(invocations: readonly InvocationModel[]): BoundarySchemaRoot[] {
|
||||
const result: BoundarySchemaRoot[] = []
|
||||
for (const invocation of invocations) {
|
||||
if (invocation.invocation.kind === 'context') {
|
||||
result.push({ key: contextBoundaryKey(invocation), type: invocation.invocation.boundary.codecType })
|
||||
}
|
||||
invocation.parameters.forEach((parameter, index) => {
|
||||
result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType })
|
||||
})
|
||||
result.push({ key: resultBoundaryKey(invocation), type: invocation.result.codecType })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function contextBoundaryKey(invocation: InvocationModel): string {
|
||||
return `${invocation.id}:context`
|
||||
}
|
||||
|
||||
function parameterBoundaryKey(invocation: InvocationModel, index: number): string {
|
||||
return `${invocation.id}:parameter:${String(index)}`
|
||||
}
|
||||
|
||||
function resultBoundaryKey(invocation: InvocationModel): string {
|
||||
return `${invocation.id}:result`
|
||||
}
|
||||
|
||||
function strictCodec(boundary: RemoteBoundaryModel, schema: string): string {
|
||||
return [
|
||||
'{',
|
||||
' mode: \'strict\',',
|
||||
` typeSymbol: ${quote(boundary.typeSymbol)},`,
|
||||
` schema: ${schema},`,
|
||||
'}',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImportModel[] {
|
||||
const imports = new Map<SymbolId, RemoteTypeImportModel>()
|
||||
const add = (boundary: RemoteBoundaryModel): void => {
|
||||
for (const imported of boundary.imports) {
|
||||
const current = imports.get(imported.symbol)
|
||||
if (current !== undefined
|
||||
&& (current.specifier !== imported.specifier || current.name !== imported.name)) {
|
||||
throw new TypertEmitError(`typert Remote emitter: symbol ${imported.symbol} has inconsistent public imports`)
|
||||
}
|
||||
imports.set(imported.symbol, imported)
|
||||
}
|
||||
}
|
||||
for (const invocation of invocations) {
|
||||
if (invocation.invocation.kind === 'context') add(invocation.invocation.boundary)
|
||||
for (const parameter of invocation.parameters) add(parameter.boundary)
|
||||
add(invocation.result)
|
||||
}
|
||||
return [...imports.values()].sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap<SymbolId, string> {
|
||||
const used = new Set(['TypeRTRemoteContribution', 'TYPERT_REMOTE'])
|
||||
const names = new Map<SymbolId, string>()
|
||||
for (const imported of imports) {
|
||||
const base = safeIdentifier(imported.name)
|
||||
let name = base
|
||||
let suffix = 2
|
||||
while (used.has(name)) name = `${base}$remote${String(suffix++)}`
|
||||
used.add(name)
|
||||
names.set(imported.symbol, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function packageExportSpecifier(packageName: string, subpath: string): string {
|
||||
return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
|
||||
}
|
||||
|
||||
@@ -94,6 +94,56 @@ export interface SchemaModel extends DocumentationModel {
|
||||
readonly type: TypeNodeId
|
||||
}
|
||||
|
||||
/** One public business type import retained for a generated Remote declaration. */
|
||||
export interface RemoteTypeImportModel {
|
||||
readonly symbol: SymbolId
|
||||
readonly specifier: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** One strict wire boundary and the public symbols needed to name it. */
|
||||
export interface RemoteBoundaryModel {
|
||||
/** Authored public type retained for generated consumer declarations. */
|
||||
readonly type: TypeNodeId
|
||||
/** Checker-resolved projection used only to emit the runtime codec. */
|
||||
readonly codecType: TypeNodeId
|
||||
readonly typeSymbol: string
|
||||
readonly imports: readonly RemoteTypeImportModel[]
|
||||
}
|
||||
|
||||
/** One ordered business argument projected onto a Remote wire field. */
|
||||
export interface InvocationParameterModel {
|
||||
readonly name: string
|
||||
readonly wire: string
|
||||
readonly source: 'json' | 'lookup'
|
||||
readonly lookup?: string
|
||||
readonly boundary: RemoteBoundaryModel
|
||||
}
|
||||
|
||||
/** One strictly analyzed Host method exported through TypeRT Gateway. */
|
||||
export interface InvocationModel {
|
||||
readonly id: string
|
||||
readonly service: string
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly implementation?: string
|
||||
readonly invocation:
|
||||
| { readonly kind: 'direct' }
|
||||
| {
|
||||
readonly kind: 'context'
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly boundary: RemoteBoundaryModel
|
||||
}
|
||||
readonly scope?: {
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
}
|
||||
readonly parameters: readonly InvocationParameterModel[]
|
||||
readonly result: RemoteBoundaryModel
|
||||
readonly location: SourceLocation
|
||||
}
|
||||
|
||||
/** Business semantics discovered in one package on one face. */
|
||||
export interface PackageModel {
|
||||
readonly name: string
|
||||
@@ -103,6 +153,7 @@ export interface PackageModel {
|
||||
readonly events: readonly EventModel[]
|
||||
readonly objects: readonly ObjectModel[]
|
||||
readonly schemas: readonly SchemaModel[]
|
||||
readonly invocations: readonly InvocationModel[]
|
||||
}
|
||||
|
||||
/** One explicit import/re-export edge between independently compiled faces. */
|
||||
@@ -173,6 +224,10 @@ export interface SignatureModel {
|
||||
export interface MemberBase extends DocumentationModel {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
/** JSON property name when a literal computed key differs from source text. */
|
||||
readonly jsonName?: string
|
||||
/** Non-literal computed keys; symbol keys are erased from JSON schemas. */
|
||||
readonly computed?: 'symbol' | 'dynamic'
|
||||
readonly optional: boolean
|
||||
readonly readonly: boolean
|
||||
readonly async: boolean
|
||||
|
||||
@@ -81,32 +81,35 @@ export class TypeGraphRenderer {
|
||||
/**
|
||||
* Render one type expression from the retained source structure.
|
||||
* @param id - type node id.
|
||||
* @param references - optional generated names for declaration references.
|
||||
* @returns TypeScript type text.
|
||||
*/
|
||||
renderType(id: TypeNodeId): string {
|
||||
renderType(id: TypeNodeId, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
const node = this.node(id)
|
||||
switch (node.kind) {
|
||||
case 'keyword': return node.name
|
||||
case 'literal': return node.text
|
||||
case 'parenthesized': return `(${this.renderType(node.type)})`
|
||||
case 'parenthesized': return `(${this.renderType(node.type, references)})`
|
||||
case 'reference': {
|
||||
const name = node.target.kind === 'type-parameter'
|
||||
? this.parameterNames.get(node.target.parameter) ?? node.name
|
||||
: node.name
|
||||
: node.target.kind === 'declaration'
|
||||
? references?.get(node.target.symbol) ?? node.name
|
||||
: node.name
|
||||
return node.arguments.length === 0
|
||||
? name
|
||||
: `${name}<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
: `${name}<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
|
||||
}
|
||||
case 'union': return node.types.map(type => this.renderType(type)).join(' | ')
|
||||
case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ')
|
||||
case 'union': return node.types.map(type => this.renderType(type, references)).join(' | ')
|
||||
case 'intersection': return node.types.map(type => this.renderType(type, references)).join(' & ')
|
||||
case 'array': {
|
||||
const element = this.renderType(node.element)
|
||||
const element = this.renderType(node.element, references)
|
||||
const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element
|
||||
return `${wrapped}[]`
|
||||
}
|
||||
case 'tuple': {
|
||||
const elements = node.elements.map((element) => {
|
||||
const type = this.renderType(element.type)
|
||||
const type = this.renderType(element.type, references)
|
||||
if (element.name !== undefined) {
|
||||
return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}`
|
||||
}
|
||||
@@ -114,34 +117,34 @@ export class TypeGraphRenderer {
|
||||
})
|
||||
return `[${elements.join(', ')}]`
|
||||
}
|
||||
case 'object': return this.renderObject(node.members)
|
||||
case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}`
|
||||
case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}`
|
||||
case 'indexed-access': return `${this.renderType(node.object)}[${this.renderType(node.index)}]`
|
||||
case 'operator': return `${node.operator} ${this.renderType(node.type)}`
|
||||
case 'object': return this.renderObject(node.members, references)
|
||||
case 'function': return `${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}`
|
||||
case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}`
|
||||
case 'indexed-access': return `${this.renderType(node.object, references)}[${this.renderType(node.index, references)}]`
|
||||
case 'operator': return `${node.operator} ${this.renderType(node.type, references)}`
|
||||
case 'conditional': {
|
||||
return `${this.renderType(node.check)} extends ${this.renderType(node.extends)} ? ${this.renderType(node.whenTrue)} : ${this.renderType(node.whenFalse)}`
|
||||
return `${this.renderType(node.check, references)} extends ${this.renderType(node.extends, references)} ? ${this.renderType(node.whenTrue, references)} : ${this.renderType(node.whenFalse, references)}`
|
||||
}
|
||||
case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false)}`
|
||||
case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false, references)}`
|
||||
case 'mapped': {
|
||||
const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly '
|
||||
const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?'
|
||||
if (node.parameter.constraint === undefined) {
|
||||
throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`)
|
||||
}
|
||||
const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}`
|
||||
const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}`
|
||||
const value = node.value === undefined ? 'unknown' : this.renderType(node.value)
|
||||
const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint, references)}`
|
||||
const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType, references)}`
|
||||
const value = node.value === undefined ? 'unknown' : this.renderType(node.value, references)
|
||||
return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }`
|
||||
}
|
||||
case 'template-literal': {
|
||||
const spans = node.spans.map(span => `\${${this.renderType(span.type)}}${escapeTemplate(span.text)}`).join('')
|
||||
const spans = node.spans.map(span => `\${${this.renderType(span.type, references)}}${escapeTemplate(span.text)}`).join('')
|
||||
return `\`${escapeTemplate(node.head)}${spans}\``
|
||||
}
|
||||
case 'type-query': {
|
||||
const argumentsText = node.arguments.length === 0
|
||||
? ''
|
||||
: `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
: `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
|
||||
return `typeof ${node.expression}${argumentsText}`
|
||||
}
|
||||
case 'import-type': {
|
||||
@@ -149,14 +152,14 @@ export class TypeGraphRenderer {
|
||||
const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}`
|
||||
const argumentsText = node.arguments.length === 0
|
||||
? ''
|
||||
: `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
: `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
|
||||
return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}`
|
||||
}
|
||||
case 'predicate': {
|
||||
const assertion = node.asserts ? 'asserts ' : ''
|
||||
return node.type === undefined
|
||||
? `${assertion}${node.parameter}`
|
||||
: `${assertion}${node.parameter} is ${this.renderType(node.type)}`
|
||||
: `${assertion}${node.parameter} is ${this.renderType(node.type, references)}`
|
||||
}
|
||||
case 'this': return 'this'
|
||||
default: return assertNever(node)
|
||||
@@ -166,34 +169,36 @@ export class TypeGraphRenderer {
|
||||
/**
|
||||
* Render a callable signature without a member name.
|
||||
* @param signature - modeled signature.
|
||||
* @param references - optional generated names for declaration references.
|
||||
* @returns parameter list and return type.
|
||||
*/
|
||||
renderSignature(signature: SignatureModel): string {
|
||||
return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}`
|
||||
renderSignature(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
return `${this.renderSignatureHead(signature, references)}: ${this.renderType(signature.returns, references)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one class/interface member as a body-free declaration.
|
||||
* @param member - modeled member.
|
||||
* @param sourceModifiers - retain source-only modifiers for reflection text.
|
||||
* @param references - optional generated names for declaration references.
|
||||
* @returns one-line TypeScript member text.
|
||||
*/
|
||||
renderMember(member: MemberModel, sourceModifiers = false): string {
|
||||
renderMember(member: MemberModel, sourceModifiers = false, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
if (sourceModifiers) return member.text
|
||||
const name = renderPropertyName(member.name)
|
||||
const optional = member.optional ? '?' : ''
|
||||
const readonly = member.readonly ? 'readonly ' : ''
|
||||
const abstract = member.abstract ? 'abstract ' : ''
|
||||
switch (member.kind) {
|
||||
case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}`
|
||||
case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}`
|
||||
case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}`
|
||||
case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}`
|
||||
case 'call': return this.renderSignature(member.signature)
|
||||
case 'construct': return `new ${this.renderSignature(member.signature)}`
|
||||
case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type, references)}`
|
||||
case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature, references)}`
|
||||
case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature, references)}`
|
||||
case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature, references)}`
|
||||
case 'call': return this.renderSignature(member.signature, references)
|
||||
case 'construct': return `new ${this.renderSignature(member.signature, references)}`
|
||||
case 'index': {
|
||||
const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')
|
||||
return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}`
|
||||
const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')
|
||||
return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns, references)}`
|
||||
}
|
||||
default: return assertNever(member)
|
||||
}
|
||||
@@ -290,38 +295,42 @@ export class TypeGraphRenderer {
|
||||
return this.graph.declarations.filter(declaration => found.has(declaration.id))
|
||||
}
|
||||
|
||||
private renderSignatureHead(signature: SignatureModel): string {
|
||||
return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})`
|
||||
private renderSignatureHead(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
return `${this.renderTypeParameters(signature.typeParameters, references)}(${signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')})`
|
||||
}
|
||||
|
||||
private renderReturn(signature: SignatureModel): string {
|
||||
return `: ${this.renderType(signature.returns)}`
|
||||
private renderReturn(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
return `: ${this.renderType(signature.returns, references)}`
|
||||
}
|
||||
|
||||
private renderParameter(parameter: ParameterModel): string {
|
||||
private renderParameter(parameter: ParameterModel, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
const name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name
|
||||
const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : ''
|
||||
const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}`
|
||||
return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type)}${initializer}`
|
||||
return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type, references)}${initializer}`
|
||||
}
|
||||
|
||||
private renderTypeParameters(parameters: readonly TypeParameterModel[]): string {
|
||||
private renderTypeParameters(parameters: readonly TypeParameterModel[], references?: ReadonlyMap<SymbolId, string>): string {
|
||||
return parameters.length === 0
|
||||
? ''
|
||||
: `<${parameters.map(parameter => this.renderTypeParameter(parameter, true)).join(', ')}>`
|
||||
: `<${parameters.map(parameter => this.renderTypeParameter(parameter, true, references)).join(', ')}>`
|
||||
}
|
||||
|
||||
private renderTypeParameter(parameter: TypeParameterModel, includeDefault: boolean): string {
|
||||
private renderTypeParameter(
|
||||
parameter: TypeParameterModel,
|
||||
includeDefault: boolean,
|
||||
references?: ReadonlyMap<SymbolId, string>,
|
||||
): string {
|
||||
const variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} `
|
||||
const constModifier = parameter.const ? 'const ' : ''
|
||||
const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}`
|
||||
const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}`
|
||||
const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint, references)}`
|
||||
const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default, references)}`
|
||||
return `${constModifier}${variance}${parameter.name}${constraint}${fallback}`
|
||||
}
|
||||
|
||||
private renderObject(members: readonly MemberModel[]): string {
|
||||
private renderObject(members: readonly MemberModel[], references?: ReadonlyMap<SymbolId, string>): string {
|
||||
if (members.length === 0) return '{}'
|
||||
return `{ ${members.map(member => `${this.renderMember(member)};`).join(' ')} }`
|
||||
return `{ ${members.map(member => `${this.renderMember(member, false, references)};`).join(' ')} }`
|
||||
}
|
||||
|
||||
private indexParameters(parameters: readonly TypeParameterModel[]): void {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Optional tsdown (rolldown) plugin face of the typert generator. When added
|
||||
* to a workspace tsdown config, it runs after each opted-in package bundle is
|
||||
* written and re-emits its model-driven face artifact at the package output
|
||||
* root. Packages without a Typert export are skipped.
|
||||
* root. Packages without a Typert or Remote export are skipped.
|
||||
* @module @deepseek-ai/dsh-typert-generator/tsdown
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { WorkspaceTypertGenerator } from './workspace.ts'
|
||||
import type { WorkspaceEmitResult } from './workspace.ts'
|
||||
import type { TypertFace } from './model.ts'
|
||||
|
||||
/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */
|
||||
interface TypertPlugin {
|
||||
@@ -17,21 +18,37 @@ interface TypertPlugin {
|
||||
writeBundle: (options: { dir?: string }) => void
|
||||
}
|
||||
|
||||
/** Generation scope selected by a tsdown build phase. */
|
||||
export interface TypertPluginOptions {
|
||||
/** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */
|
||||
readonly mode?: 'package' | 'workspace'
|
||||
/** Independent TypeScript program faces included in this phase. */
|
||||
readonly faces?: readonly TypertFace[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the typert generation plugin for the root tsdown config.
|
||||
* @returns a rolldown-compatible plugin that emits `lib/typert.<face>.js` and `.d.ts` for contributing packages.
|
||||
* @param pluginOptions - package/workspace emission mode and independent program faces.
|
||||
* @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts.
|
||||
*/
|
||||
export function typertPlugin(): TypertPlugin {
|
||||
export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin {
|
||||
const artifactsByRoot = new Map<string, readonly WorkspaceEmitResult[]>()
|
||||
const emittedWorkspaces = new Set<string>()
|
||||
return {
|
||||
name: 'dsh-typert-generator',
|
||||
writeBundle(options) {
|
||||
writeBundle(bundleOptions) {
|
||||
// options.dir is the package's absolute outDir (<package>/lib); its
|
||||
// nearest package.json owns the bundle even when a custom config writes
|
||||
// a nested output such as <package>/lib/dev.
|
||||
if (options.dir === undefined) return
|
||||
const root = workspaceRoot(options.dir)
|
||||
const packageDir = packageRoot(options.dir, root)
|
||||
if (bundleOptions.dir === undefined) return
|
||||
const root = workspaceRoot(bundleOptions.dir)
|
||||
if (emittedWorkspaces.has(root)) return
|
||||
if (pluginOptions.mode === 'workspace') {
|
||||
emitWorkspace(root, pluginOptions.faces)
|
||||
emittedWorkspaces.add(root)
|
||||
return
|
||||
}
|
||||
const packageDir = packageRoot(bundleOptions.dir, root)
|
||||
if (packageDir === undefined) return
|
||||
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
|
||||
name?: string
|
||||
@@ -40,22 +57,54 @@ export function typertPlugin(): TypertPlugin {
|
||||
if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return
|
||||
let artifacts = artifactsByRoot.get(root)
|
||||
if (artifacts === undefined) {
|
||||
artifacts = new WorkspaceTypertGenerator(root).generate()
|
||||
const generator = new WorkspaceTypertGenerator(root)
|
||||
artifacts = pluginOptions.faces === undefined
|
||||
? generator.generate()
|
||||
: generator.generate(undefined, pluginOptions.faces)
|
||||
artifactsByRoot.set(root, artifacts)
|
||||
}
|
||||
const output = join(packageDir, 'lib')
|
||||
mkdirSync(output, { recursive: true })
|
||||
for (const artifact of artifacts.filter(candidate => candidate.package === manifest.name)) {
|
||||
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
|
||||
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
|
||||
}
|
||||
emitArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name))
|
||||
},
|
||||
}
|
||||
|
||||
function emitWorkspace(root: string, faces: readonly TypertFace[] | undefined): void {
|
||||
const generator = new WorkspaceTypertGenerator(root)
|
||||
const packages = generator.discover(faces)
|
||||
.filter(candidate => hasTypertExport(readManifest(join(root, candidate.root)).exports))
|
||||
.map(candidate => candidate.package)
|
||||
if (packages.length === 0) return
|
||||
for (const artifact of generator.generate(packages, faces)) {
|
||||
emitArtifacts(join(root, artifact.packageRoot), [artifact])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void {
|
||||
const output = join(packageDir, 'lib')
|
||||
mkdirSync(output, { recursive: true })
|
||||
for (const artifact of artifacts) {
|
||||
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
|
||||
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
|
||||
if (artifact.remote !== undefined) {
|
||||
writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js)
|
||||
writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts)
|
||||
writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(packageDir: string): { name?: string; exports?: unknown } {
|
||||
return JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
|
||||
name?: string
|
||||
exports?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function hasTypertExport(exportsField: unknown): boolean {
|
||||
if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false
|
||||
return Object.hasOwn(exportsField, './typert') || Object.hasOwn(exportsField, './client/typert')
|
||||
return Object.hasOwn(exportsField, './typert')
|
||||
|| Object.hasOwn(exportsField, './client/typert')
|
||||
|| Object.hasOwn(exportsField, './remote')
|
||||
}
|
||||
|
||||
function packageRoot(start: string, workspace: string): string | undefined {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts'
|
||||
import type { DiscoveredTypertPackage } from './analyzer.ts'
|
||||
import { FaceModelEmitter } from './emitter.ts'
|
||||
import type { ModelEmitResult } from './emitter.ts'
|
||||
import type { TypertFace } from './model.ts'
|
||||
|
||||
/** One emitted artifact paired with its source package root. */
|
||||
export interface WorkspaceEmitResult extends ModelEmitResult {
|
||||
@@ -26,20 +27,29 @@ export class WorkspaceTypertGenerator {
|
||||
/**
|
||||
* Find public package faces that contribute Cordis services/events or
|
||||
* explicitly tagged Typert roots.
|
||||
* @param faces - optional independent program faces to inspect.
|
||||
* @returns discovered packages in stable package-name order.
|
||||
*/
|
||||
discover(): DiscoveredTypertPackage[] {
|
||||
return new WorkspaceAnalyzer({ root: this.root }).discoverPackages()
|
||||
discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] {
|
||||
return new WorkspaceAnalyzer({
|
||||
root: this.root,
|
||||
...(faces === undefined ? {} : { faces }),
|
||||
}).discoverPackages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all discovered contributors, or an explicit package subset.
|
||||
* @param packages - optional exact package names for a focused pass.
|
||||
* @param faces - optional independent program faces to analyze.
|
||||
* @returns one artifact per package face.
|
||||
*/
|
||||
generate(packages?: readonly string[]): WorkspaceEmitResult[] {
|
||||
const selected = packages ?? this.discover().map(candidate => candidate.package)
|
||||
const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze()
|
||||
generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] {
|
||||
const selected = packages ?? this.discover(faces).map(candidate => candidate.package)
|
||||
const workspace = new WorkspaceAnalyzer({
|
||||
root: this.root,
|
||||
packages: selected,
|
||||
...(faces === undefined ? {} : { faces }),
|
||||
}).analyze()
|
||||
const artifacts: WorkspaceEmitResult[] = []
|
||||
for (const face of workspace.faces) {
|
||||
const emitter = new FaceModelEmitter(face)
|
||||
@@ -80,6 +90,28 @@ export class WorkspaceTypertGenerator {
|
||||
throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`)
|
||||
}
|
||||
}
|
||||
if (artifact.remote === undefined) return
|
||||
const remoteExpected = {
|
||||
types: './lib/typert.remote-client.d.ts',
|
||||
default: './lib/typert.remote-client.js',
|
||||
}
|
||||
const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object'
|
||||
? (manifest.exports as Record<string, unknown>)['./remote']
|
||||
: undefined
|
||||
if (!sameExport(remoteActual, remoteExpected)) {
|
||||
throw new TypertAnalysisError(
|
||||
`typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`,
|
||||
)
|
||||
}
|
||||
for (const file of [
|
||||
'lib/typert.remote-client.js',
|
||||
'lib/typert.remote-client.d.ts',
|
||||
'lib/typert.remote-client.d.ts.map',
|
||||
]) {
|
||||
if (!files.includes(file)) {
|
||||
throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export const TYPERT = {
|
||||
schemas: [
|
||||
{ name: 'Payload', schema: Payload },
|
||||
],
|
||||
invocations: [
|
||||
],
|
||||
model: {
|
||||
"services": [
|
||||
{
|
||||
@@ -3815,6 +3817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
|
||||
"abstract": false,
|
||||
"async": false,
|
||||
"id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756",
|
||||
"jsonName": "computed",
|
||||
"kind": "property",
|
||||
"location": {
|
||||
"column": 5,
|
||||
@@ -5634,6 +5637,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
|
||||
"symbol": "@fixture/host:packages/host/src/models.ts#Variance",
|
||||
},
|
||||
],
|
||||
"invocations": [],
|
||||
"name": "@fixture/host",
|
||||
"objects": [
|
||||
{
|
||||
@@ -6449,6 +6453,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
|
||||
"symbol": "<external>:../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType",
|
||||
},
|
||||
],
|
||||
"invocations": [],
|
||||
"name": "@fixture/client",
|
||||
"objects": [],
|
||||
"root": "packages/client",
|
||||
|
||||
5
packages/typert/generator/tests/fixtures/remote-model/package.json
vendored
Normal file
5
packages/typert/generator/tests/fixtures/remote-model/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@fixture/remote-workspace",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
9
packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json
vendored
Normal file
9
packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@fixture/domain",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts"
|
||||
}
|
||||
}
|
||||
19
packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts
vendored
Normal file
19
packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { AgentId } from './types.ts'
|
||||
|
||||
/** Host-only live Agent object. */
|
||||
export class Agent {
|
||||
constructor(readonly id: AgentId) {}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
agent: TypeRTLookup<Agent, AgentId>
|
||||
}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
agent: TypeRTContext<AgentId>
|
||||
}
|
||||
}
|
||||
|
||||
export type { AgentId } from './types.ts'
|
||||
2
packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts
vendored
Normal file
2
packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Stable Agent identity crossing the Remote boundary. */
|
||||
export type AgentId = string
|
||||
11
packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json
vendored
Normal file
11
packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
24
packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json
vendored
Normal file
24
packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@fixture/remote",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts",
|
||||
"lib/typert.remote-client.d.ts.map"
|
||||
]
|
||||
}
|
||||
30
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts
vendored
Normal file
30
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { Agent } from '@fixture/domain'
|
||||
import type {
|
||||
CreateGoalRequest,
|
||||
CreateGoalResult,
|
||||
RenameGoalRequest,
|
||||
RenameGoalResult,
|
||||
} from './types.ts'
|
||||
|
||||
/** Remote-only business Service with no Cordis declaration merge. */
|
||||
export class GoalService {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
@Remote
|
||||
async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {
|
||||
return { ref: `${agent.id}:${request.title}` }
|
||||
}
|
||||
|
||||
@RemoteContext('agent')
|
||||
rename(request: RenameGoalRequest): RenameGoalResult {
|
||||
return { renamed: request.title.length > 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
CreateGoalRequest,
|
||||
CreateGoalResult,
|
||||
RenameGoalRequest,
|
||||
RenameGoalResult,
|
||||
} from './types.ts'
|
||||
20
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts
vendored
Normal file
20
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Input accepted by Goal creation. */
|
||||
export interface CreateGoalRequest {
|
||||
readonly title: string
|
||||
}
|
||||
|
||||
/** Wire-safe Goal creation result. */
|
||||
export interface CreateGoalResult {
|
||||
readonly ref: string
|
||||
}
|
||||
|
||||
/** Input accepted by scoped Goal renaming. */
|
||||
export interface RenameGoalRequest {
|
||||
readonly ref: string
|
||||
readonly title: string
|
||||
}
|
||||
|
||||
/** Wire-safe Goal rename result. */
|
||||
export interface RenameGoalResult {
|
||||
readonly renamed: boolean
|
||||
}
|
||||
14
packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json
vendored
Normal file
14
packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../domain" }
|
||||
]
|
||||
}
|
||||
20
packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json
vendored
Normal file
20
packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@deepseek-ai/dsh-type-meta": ["./type-meta.d.ts"],
|
||||
"@fixture/domain": ["./packages/domain/src/index.ts"],
|
||||
"@fixture/domain/*": ["./packages/domain/src/*"],
|
||||
"@fixture/remote": ["./packages/remote/src/index.ts"],
|
||||
"@fixture/remote/*": ["./packages/remote/src/*"]
|
||||
},
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
8
packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json
vendored
Normal file
8
packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/domain" },
|
||||
{ "path": "./packages/remote" }
|
||||
]
|
||||
}
|
||||
45
packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts
vendored
Normal file
45
packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
export interface TypeRTLookup<Host, Wire> {
|
||||
readonly host: Host
|
||||
readonly wire: Wire
|
||||
}
|
||||
|
||||
export interface TypeRTContext<Wire> {
|
||||
readonly wire: Wire
|
||||
}
|
||||
|
||||
export interface TypeRTLookupMap {}
|
||||
export interface TypeRTContextMap {}
|
||||
export interface TypeRTRemoteMap {}
|
||||
export interface TypeRTRemoteContextMap {}
|
||||
|
||||
export type TypeRTRemoteNamespace<Namespace extends string> = {
|
||||
[Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}`
|
||||
? Method
|
||||
: never]: TypeRTRemoteMap[Endpoint]
|
||||
}
|
||||
|
||||
export interface TypeRTRemoteNamespaceMap {}
|
||||
|
||||
export interface TypeRTRemoteContribution {
|
||||
readonly package: string
|
||||
readonly descriptors: readonly unknown[]
|
||||
}
|
||||
|
||||
export function bindTypeRTGateway<Service extends object>(
|
||||
service: Service,
|
||||
serviceKey: string,
|
||||
options?: { readonly namespace?: string },
|
||||
): { readonly service: Service; readonly serviceKey: string; readonly namespace: string }
|
||||
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void
|
||||
|
||||
export function RemoteContext(key: Extract<keyof TypeRTContextMap, string>):
|
||||
<This extends object, Args extends unknown[], Result>(
|
||||
method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
) => void
|
||||
}
|
||||
486
packages/typert/generator/tests/remote-model.spec.ts
Normal file
486
packages/typert/generator/tests/remote-model.spec.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { WorkspaceAnalyzer } from '../src/analyzer.ts'
|
||||
import type { InvocationModel } from '../src/model.ts'
|
||||
import { WorkspaceTypertGenerator } from '../src/workspace.ts'
|
||||
|
||||
const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model')
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
interface RuntimeSchema {
|
||||
safeParse(value: unknown): { readonly success: boolean }
|
||||
}
|
||||
|
||||
interface RuntimeDescriptor {
|
||||
readonly id: string
|
||||
readonly parameters: readonly {
|
||||
readonly wire: string
|
||||
readonly codec: { readonly schema: RuntimeSchema }
|
||||
}[]
|
||||
readonly result: { readonly schema: RuntimeSchema }
|
||||
}
|
||||
|
||||
interface RuntimeRemoteModule {
|
||||
readonly TYPERT_REMOTE: {
|
||||
readonly package: string
|
||||
readonly descriptors: readonly RuntimeDescriptor[]
|
||||
}
|
||||
}
|
||||
|
||||
interface RemoteDeclarationMap {
|
||||
readonly file: string
|
||||
readonly names: readonly string[]
|
||||
readonly sources: readonly string[]
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
it('discovers a Remote-only package and emits strict direct and Context descriptors', async () => {
|
||||
const generator = new WorkspaceTypertGenerator(fixtureRoot)
|
||||
|
||||
expect(generator.discover()).toEqual([{
|
||||
package: '@fixture/remote',
|
||||
root: 'packages/remote',
|
||||
faces: ['host'],
|
||||
}])
|
||||
|
||||
const [artifact] = generator.generate()
|
||||
expect(artifact).toBeDefined()
|
||||
expect(artifact).toMatchObject({
|
||||
package: '@fixture/remote',
|
||||
face: 'host',
|
||||
packageRoot: 'packages/remote',
|
||||
})
|
||||
|
||||
const model = remotePackage(fixtureRoot)
|
||||
expect(model.services).toEqual([])
|
||||
expect(model.invocations).toHaveLength(2)
|
||||
expect(model.invocations[0]).toMatchObject({
|
||||
id: '@fixture/remote#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'agent', wire: 'agentId' },
|
||||
parameters: [
|
||||
{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'agent',
|
||||
boundary: { typeSymbol: '@fixture/domain/types#AgentId' },
|
||||
},
|
||||
{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' },
|
||||
},
|
||||
],
|
||||
result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' },
|
||||
})
|
||||
expect(model.invocations[1]).toMatchObject({
|
||||
id: '@fixture/remote#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'agent',
|
||||
wire: 'agentId',
|
||||
boundary: { typeSymbol: '@fixture/domain/types#AgentId' },
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
boundary: { typeSymbol: '@fixture/remote/types#RenameGoalRequest' },
|
||||
}],
|
||||
result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' },
|
||||
})
|
||||
|
||||
expect(artifact?.js).toContain('invocations: [')
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise<CreateGoalResult>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:')
|
||||
expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73")
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/create': (request: CreateGoalRequest) => Promise<CreateGoalResult>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/rename': (request: RenameGoalRequest) => Promise<RenameGoalResult>",
|
||||
)
|
||||
|
||||
const remoteJs = artifact?.remote?.js
|
||||
if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript')
|
||||
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
|
||||
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
|
||||
expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote')
|
||||
const create = generated.TYPERT_REMOTE.descriptors[0]
|
||||
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true)
|
||||
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
|
||||
expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
|
||||
expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false)
|
||||
|
||||
const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap
|
||||
expect(declarationMap).toMatchObject({
|
||||
file: 'typert.remote-client.d.ts',
|
||||
sources: ['../src/index.ts'],
|
||||
})
|
||||
expect(declarationMap.names).toContain('create')
|
||||
|
||||
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap)
|
||||
})
|
||||
|
||||
it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => `${source}
|
||||
|
||||
/** Recursive JSON fixture used by the concrete codec projection. */
|
||||
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
|
||||
|
||||
/** Merge-extensible operation table represented by concrete fixture entries. */
|
||||
export interface GenericRemoteMap {
|
||||
ship: {
|
||||
readonly request: { readonly count: number; readonly meta: Json }
|
||||
readonly result: { readonly accepted: boolean }
|
||||
}
|
||||
cancel: {
|
||||
readonly request: { readonly reason: string }
|
||||
readonly result: { readonly cancelled: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
type GenericRemoteKey = Extract<keyof GenericRemoteMap, string>
|
||||
type RequestOf<K extends GenericRemoteKey> = GenericRemoteMap[K] extends { readonly request: infer Request }
|
||||
? Request
|
||||
: never
|
||||
type ResultOf<K extends GenericRemoteKey> = GenericRemoteMap[K] extends { readonly result: infer Result }
|
||||
? Result
|
||||
: never
|
||||
|
||||
/** Strict request union retained in the generated Client declaration. */
|
||||
export type GenericRequest = {
|
||||
[K in GenericRemoteKey]: { readonly kind: K; readonly payload: RequestOf<K> }
|
||||
}[GenericRemoteKey]
|
||||
|
||||
/** Strict result union retained in the generated Client declaration. */
|
||||
export type GenericResult = {
|
||||
[K in GenericRemoteKey]: { readonly kind: K; readonly value: ResultOf<K> }
|
||||
}[GenericRemoteKey]
|
||||
`)
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source
|
||||
.replace(
|
||||
' RenameGoalResult,\n',
|
||||
' RenameGoalResult,\n GenericRequest,\n GenericResult,\n',
|
||||
)
|
||||
.replace(
|
||||
' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}',
|
||||
` rename(request: RenameGoalRequest): RenameGoalResult {
|
||||
return { renamed: request.title.length > 0 }
|
||||
}
|
||||
|
||||
@Remote
|
||||
dispatch(request: GenericRequest): GenericResult {
|
||||
if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } }
|
||||
return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } }
|
||||
}
|
||||
}`,
|
||||
))
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/dispatch': (request: GenericRequest) => Promise<GenericResult>",
|
||||
)
|
||||
const remoteJs = artifact?.remote?.js
|
||||
if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript')
|
||||
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
|
||||
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
|
||||
const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch'))
|
||||
const schema = dispatch?.parameters[0]?.codec.schema
|
||||
expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true)
|
||||
expect(schema?.safeParse({ kind: 'ship', payload: { count: '2', meta: {} } }).success).toBe(false)
|
||||
expect(schema?.safeParse({ kind: 'cancel', payload: { reason: 'obsolete' } }).success).toBe(true)
|
||||
expect(schema?.safeParse({ kind: 'unknown', payload: {} }).success).toBe(false)
|
||||
expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { accepted: true } }).success).toBe(true)
|
||||
expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'missing binding',
|
||||
edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''),
|
||||
message: 'Remote methods require readonly typertGateway',
|
||||
},
|
||||
{
|
||||
name: 'private method',
|
||||
edit: (source: string) => source.replace(' async create(', ' private async create('),
|
||||
message: 'Remote decorators require a public instance method',
|
||||
},
|
||||
{
|
||||
name: 'static method',
|
||||
edit: (source: string) => source.replace(' async create(', ' static async create('),
|
||||
message: 'Remote decorators require a public instance method',
|
||||
},
|
||||
{
|
||||
name: 'abstract method',
|
||||
edit: (source: string) => source
|
||||
.replace('export class GoalService', 'export abstract class GoalService')
|
||||
.replace(
|
||||
' async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {\n return { ref: `${agent.id}:${request.title}` }\n }',
|
||||
' abstract create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult>',
|
||||
),
|
||||
message: 'Remote methods must have a concrete implementation',
|
||||
},
|
||||
{
|
||||
name: 'generic method',
|
||||
edit: (source: string) => source.replace(' async create(', ' async create<Value>('),
|
||||
message: 'generic Remote methods are not supported',
|
||||
},
|
||||
{
|
||||
name: 'destructured parameter',
|
||||
edit: (source: string) => source.replace('request: CreateGoalRequest', '{ title }: CreateGoalRequest'),
|
||||
message: 'Remote parameters must use identifier bindings',
|
||||
},
|
||||
{
|
||||
name: 'rest parameter',
|
||||
edit: (source: string) => source.replace('request: CreateGoalRequest', '...request: [CreateGoalRequest]'),
|
||||
message: 'Remote parameters cannot be rest parameters',
|
||||
},
|
||||
{
|
||||
name: 'default parameter',
|
||||
edit: (source: string) => source.replace(
|
||||
'request: CreateGoalRequest',
|
||||
"request: CreateGoalRequest = { title: '' }",
|
||||
),
|
||||
message: 'Remote parameters cannot have default values',
|
||||
},
|
||||
{
|
||||
name: 'optional parameter',
|
||||
edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'),
|
||||
message: 'Remote parameters cannot be optional',
|
||||
},
|
||||
])('rejects $name', ({ edit, message }) => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', edit)
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message))
|
||||
})
|
||||
|
||||
it('rejects a workspace class parameter without a lookup declaration', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/domain/src/index.ts', source => source.replace(
|
||||
' interface TypeRTLookupMap {\n agent: TypeRTLookup<Agent, AgentId>\n }\n\n',
|
||||
'',
|
||||
))
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/)
|
||||
})
|
||||
|
||||
it('rejects a Remote Context without a static Context declaration', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')"))
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/)
|
||||
})
|
||||
|
||||
it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/domain/src/types.ts', source => `${source}\n/** Deliberately distinct Context identity for the failure fixture. */\nexport type OtherAgentId = string\n`)
|
||||
editFile(root, 'packages/domain/src/index.ts', source => source
|
||||
.replace("import type { AgentId } from './types.ts'", "import type { AgentId, OtherAgentId } from './types.ts'")
|
||||
.replace('agent: TypeRTContext<AgentId>', 'agent: TypeRTContext<OtherAgentId>'))
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/Remote scope agent wire type .* does not match lookup wire type/)
|
||||
})
|
||||
|
||||
it('rejects duplicate endpoints across Remote services', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => `${source}
|
||||
export class DuplicateGoalService {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' })
|
||||
|
||||
@Remote
|
||||
create(request: CreateGoalRequest): CreateGoalResult {
|
||||
return { ref: request.title }
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/Remote endpoint goals\/create conflicts/)
|
||||
})
|
||||
})
|
||||
|
||||
function analyzeRemote(root: string, checkDiagnostics = true): ReturnType<WorkspaceAnalyzer['analyze']> {
|
||||
return new WorkspaceAnalyzer({ root, checkDiagnostics }).analyze()
|
||||
}
|
||||
|
||||
function remotePackage(root: string): {
|
||||
readonly services: readonly unknown[]
|
||||
readonly invocations: readonly InvocationModel[]
|
||||
} {
|
||||
const host = analyzeRemote(root).faces.find(face => face.face === 'host')
|
||||
const packageModel = host?.packages.find(candidate => candidate.name === '@fixture/remote')
|
||||
if (packageModel === undefined) throw new Error('Remote fixture package was not modeled on the host face')
|
||||
return packageModel
|
||||
}
|
||||
|
||||
function copyFixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-'))
|
||||
cpSync(fixtureRoot, root, { recursive: true })
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function editFile(root: string, relativePath: string, edit: (source: string) => string): void {
|
||||
const path = join(root, relativePath)
|
||||
const source = readFileSync(path, 'utf8')
|
||||
const result = edit(source)
|
||||
if (result === source) throw new Error(`fixture edit made no change to ${relativePath}`)
|
||||
writeFileSync(path, result)
|
||||
}
|
||||
|
||||
function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void {
|
||||
if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration')
|
||||
if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map')
|
||||
const consumerRoot = copyFixture()
|
||||
const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts')
|
||||
const declarationMapPath = `${declarationPath}.map`
|
||||
const consumerPath = join(consumerRoot, 'consumer.ts')
|
||||
mkdirSync(join(consumerRoot, 'packages/remote/lib'), { recursive: true })
|
||||
writeFileSync(declarationPath, dts, { flush: true })
|
||||
writeFileSync(declarationMapPath, dtsMap, { flush: true })
|
||||
assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot)
|
||||
const consumerSource = `
|
||||
import remote from '@fixture/remote/remote'
|
||||
import type {
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteContextMap,
|
||||
TypeRTRemoteMap,
|
||||
TypeRTRemoteNamespaceMap,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types'
|
||||
|
||||
const contribution: TypeRTRemoteContribution = remote
|
||||
declare const create: TypeRTRemoteMap['goals/create']
|
||||
declare const createScoped: TypeRTRemoteContextMap['agent:goals/create']
|
||||
declare const rename: TypeRTRemoteContextMap['agent:goals/rename']
|
||||
const created: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' })
|
||||
const createdScoped: Promise<CreateGoalResult> = createScoped({ title: 'ship' })
|
||||
const renamed: Promise<RenameGoalResult> = rename({ ref: 'goal-1', title: 'land' })
|
||||
declare const ctx: { api: TypeRTRemoteNamespaceMap }
|
||||
const navigated: Promise<CreateGoalResult> = ctx.api.goals.create('agent-1', { title: 'navigate' })
|
||||
void contribution
|
||||
void created
|
||||
void createdScoped
|
||||
void renamed
|
||||
void navigated
|
||||
`
|
||||
writeFileSync(consumerPath, consumerSource)
|
||||
const configPath = join(consumerRoot, 'tsconfig.consumer.json')
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
extends: './tsconfig.base.json',
|
||||
compilerOptions: {
|
||||
composite: false,
|
||||
skipLibCheck: false,
|
||||
paths: {
|
||||
'@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'],
|
||||
'@fixture/domain/types': ['./packages/domain/src/types.ts'],
|
||||
'@fixture/remote/types': ['./packages/remote/src/types.ts'],
|
||||
'@fixture/remote/remote': ['./packages/remote/lib/typert.remote-client.d.ts'],
|
||||
},
|
||||
},
|
||||
files: ['./consumer.ts'],
|
||||
}, null, 2))
|
||||
const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
|
||||
if (config.error !== undefined) throw new Error(formatDiagnostics([config.error]))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath)
|
||||
const program = ts.createProgram(parsed.fileNames, parsed.options)
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program)
|
||||
expect(diagnostics, formatDiagnostics(diagnostics)).toEqual([])
|
||||
|
||||
const languageService = ts.createLanguageService({
|
||||
getCompilationSettings: () => parsed.options,
|
||||
getCurrentDirectory: () => consumerRoot,
|
||||
getDefaultLibFileName: options => ts.getDefaultLibFilePath(options),
|
||||
getScriptFileNames: () => parsed.fileNames,
|
||||
getScriptSnapshot: (fileName) => {
|
||||
const source = ts.sys.readFile(fileName)
|
||||
return source === undefined ? undefined : ts.ScriptSnapshot.fromString(source)
|
||||
},
|
||||
getScriptVersion: () => '0',
|
||||
directoryExists: path => ts.sys.directoryExists(path),
|
||||
fileExists: path => ts.sys.fileExists(path),
|
||||
getDirectories: path => ts.sys.getDirectories(path),
|
||||
readDirectory: (path, extensions, exclude, include, depth) =>
|
||||
ts.sys.readDirectory(path, extensions, exclude, include, depth),
|
||||
readFile: path => ts.sys.readFile(path),
|
||||
realpath: path => ts.sys.realpath?.(path) ?? path,
|
||||
})
|
||||
const navigation = 'ctx.api.goals.create'
|
||||
const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1
|
||||
const definitions = languageService.getDefinitionAtPosition(consumerPath, position)
|
||||
const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath)
|
||||
if (generatedDefinition === undefined) {
|
||||
throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`)
|
||||
}
|
||||
const sourceMapper = (languageService as unknown as {
|
||||
getSourceMapper(): {
|
||||
tryGetSourcePosition(location: { readonly fileName: string; readonly pos: number }):
|
||||
{ readonly fileName: string; readonly pos: number } | undefined
|
||||
}
|
||||
}).getSourceMapper()
|
||||
const definition = sourceMapper.tryGetSourcePosition({
|
||||
fileName: generatedDefinition.fileName,
|
||||
pos: generatedDefinition.textSpan.start,
|
||||
})
|
||||
languageService.dispose()
|
||||
if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) {
|
||||
throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`)
|
||||
}
|
||||
const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8')
|
||||
expect(hostSource.slice(definition.pos, definition.pos + generatedDefinition.textSpan.length)).toBe('create')
|
||||
}
|
||||
|
||||
function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): void {
|
||||
const consumerPath = join(consumerRoot, 'consumer-without-remote.ts')
|
||||
writeFileSync(consumerPath, `
|
||||
import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta'
|
||||
declare const ctx: { api: TypeRTRemoteNamespaceMap }
|
||||
ctx.api.goals.create('agent-1', { title: 'must not compile' })
|
||||
`)
|
||||
const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json')
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
extends: './tsconfig.base.json',
|
||||
compilerOptions: {
|
||||
composite: false,
|
||||
skipLibCheck: false,
|
||||
paths: {
|
||||
'@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'],
|
||||
},
|
||||
},
|
||||
files: ['./consumer-without-remote.ts'],
|
||||
}, null, 2))
|
||||
const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
|
||||
if (config.error !== undefined) throw new Error(formatDiagnostics([config.error]))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath)
|
||||
const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options))
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.code).toBe(2339)
|
||||
expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist")
|
||||
}
|
||||
|
||||
function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string {
|
||||
return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
|
||||
getCanonicalFileName: file => file,
|
||||
getCurrentDirectory: () => process.cwd(),
|
||||
getNewLine: () => '\n',
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
FaceModel,
|
||||
KeywordTypeName,
|
||||
MemberModel,
|
||||
SignatureMemberModel,
|
||||
SignatureModel,
|
||||
TypeDeclarationModel,
|
||||
TypeNodeModel,
|
||||
@@ -356,6 +357,149 @@ describe('SchemaEmitter supported projection matrix', () => {
|
||||
expect(inheritedSchema.safeParse({ current: 1 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('instantiates generic aliases, nested references, defaults, and recursive declarations', async () => {
|
||||
const box = declaration('Box', 'interface', {
|
||||
typeParameters: [{ id: 'box:value', name: 'Value', const: false }],
|
||||
members: [property('value', 'box:value-reference')],
|
||||
})
|
||||
const wrapper = declaration('Wrapper', 'alias', {
|
||||
typeParameters: [
|
||||
{ id: 'wrapper:value', name: 'Value', const: false },
|
||||
{ id: 'wrapper:items', name: 'Items', const: false, default: 'wrapper:default-items' },
|
||||
],
|
||||
type: 'wrapper:box-reference',
|
||||
})
|
||||
const recursive = declaration('Recursive', 'interface', {
|
||||
typeParameters: [{ id: 'recursive:value', name: 'Value', const: false }],
|
||||
members: [
|
||||
property('value', 'recursive:value-reference'),
|
||||
property('next', 'recursive:self-reference', { optional: true }),
|
||||
],
|
||||
})
|
||||
const schema = await loadSchema(emit([
|
||||
{
|
||||
id: 'root',
|
||||
kind: 'object',
|
||||
members: [
|
||||
property('wrapped', 'root:wrapper-reference'),
|
||||
property('recursive', 'root:recursive-reference'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'root:wrapper-reference',
|
||||
kind: 'reference',
|
||||
name: 'Wrapper',
|
||||
target: { kind: 'declaration', symbol: 'Wrapper' },
|
||||
arguments: ['string'],
|
||||
},
|
||||
{
|
||||
id: 'root:recursive-reference',
|
||||
kind: 'reference',
|
||||
name: 'Recursive',
|
||||
target: { kind: 'declaration', symbol: 'Recursive' },
|
||||
arguments: ['number'],
|
||||
},
|
||||
{
|
||||
id: 'wrapper:box-reference',
|
||||
kind: 'reference',
|
||||
name: 'Box',
|
||||
target: { kind: 'declaration', symbol: 'Box' },
|
||||
arguments: ['wrapper:items-reference'],
|
||||
},
|
||||
{
|
||||
id: 'wrapper:default-items',
|
||||
kind: 'reference',
|
||||
name: 'ReadonlyArray',
|
||||
target: { kind: 'standard', name: 'ReadonlyArray' },
|
||||
arguments: ['wrapper:value-reference'],
|
||||
},
|
||||
{
|
||||
id: 'wrapper:value-reference',
|
||||
kind: 'reference',
|
||||
name: 'Value',
|
||||
target: { kind: 'type-parameter', parameter: 'wrapper:value' },
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
id: 'wrapper:items-reference',
|
||||
kind: 'reference',
|
||||
name: 'Items',
|
||||
target: { kind: 'type-parameter', parameter: 'wrapper:items' },
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
id: 'box:value-reference',
|
||||
kind: 'reference',
|
||||
name: 'Value',
|
||||
target: { kind: 'type-parameter', parameter: 'box:value' },
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
id: 'recursive:value-reference',
|
||||
kind: 'reference',
|
||||
name: 'Value',
|
||||
target: { kind: 'type-parameter', parameter: 'recursive:value' },
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
id: 'recursive:self-reference',
|
||||
kind: 'reference',
|
||||
name: 'Recursive',
|
||||
target: { kind: 'declaration', symbol: 'Recursive' },
|
||||
arguments: ['recursive:value-reference'],
|
||||
},
|
||||
keyword('string', 'string'),
|
||||
keyword('number', 'number'),
|
||||
], undefined, [box, wrapper, recursive]))
|
||||
|
||||
expect(schema.safeParse({
|
||||
wrapped: { value: ['one', 'two'] },
|
||||
recursive: { value: 1, next: { value: 2 } },
|
||||
}).success).toBe(true)
|
||||
expect(schema.safeParse({
|
||||
wrapped: { value: [1] },
|
||||
recursive: { value: 1 },
|
||||
}).success).toBe(false)
|
||||
expect(schema.safeParse({
|
||||
wrapped: { value: ['one'] },
|
||||
recursive: { value: 'one' },
|
||||
}).success).toBe(false)
|
||||
})
|
||||
|
||||
it('erases unique-symbol nominal members without naming a branding utility', async () => {
|
||||
const nominal = declaration('Nominal', 'alias', {
|
||||
typeParameters: [{ id: 'nominal:brand', name: 'Brand', const: false }],
|
||||
type: 'nominal:intersection',
|
||||
})
|
||||
const symbolMember = {
|
||||
...property('[TOKEN]', 'nominal:brand-reference', { readonly: true }),
|
||||
computed: 'symbol',
|
||||
} as const
|
||||
const schema = await loadSchema(emit([
|
||||
{
|
||||
id: 'root',
|
||||
kind: 'reference',
|
||||
name: 'Nominal',
|
||||
target: { kind: 'declaration', symbol: 'Nominal' },
|
||||
arguments: ['brand'],
|
||||
},
|
||||
{ id: 'brand', kind: 'literal', value: 'Fixture', text: "'Fixture'" },
|
||||
{ id: 'nominal:intersection', kind: 'intersection', types: ['string', 'nominal:marker'] },
|
||||
keyword('string', 'string'),
|
||||
{ id: 'nominal:marker', kind: 'object', members: [symbolMember] },
|
||||
{
|
||||
id: 'nominal:brand-reference',
|
||||
kind: 'reference',
|
||||
name: 'Brand',
|
||||
target: { kind: 'type-parameter', parameter: 'nominal:brand' },
|
||||
arguments: [],
|
||||
},
|
||||
], undefined, [nominal]))
|
||||
|
||||
expect(schema.safeParse('fixture-id').success).toBe(true)
|
||||
expect(schema.safeParse(1).success).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies every TypeNode kind and executes every supported kind', () => {
|
||||
const expected = Object.entries(ZOD_NODE_SUPPORT)
|
||||
.filter(([, support]) => support === 'supported')
|
||||
@@ -373,7 +517,6 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['type-parameter', { kind: 'type-parameter', parameter: 'parameter' }],
|
||||
['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }],
|
||||
['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }],
|
||||
] as const)('rejects %s references explicitly', (kind, target) => {
|
||||
@@ -386,7 +529,33 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
}])).toThrow(`typert Zod emitter: Value: ${kind} reference has no Zod projection`)
|
||||
})
|
||||
|
||||
it('rejects unsupported standard references, generic declarations, and enums', () => {
|
||||
it('rejects unbound type parameters, incomplete generic applications, and generic schema exports', () => {
|
||||
expect(() => emit([{
|
||||
id: 'root',
|
||||
kind: 'reference',
|
||||
name: 'Value',
|
||||
target: { kind: 'type-parameter', parameter: 'parameter' },
|
||||
arguments: [],
|
||||
}])).toThrow('type parameter has no schema substitution')
|
||||
|
||||
const generic = declaration('Generic', 'interface', {
|
||||
typeParameters: [{ id: 'parameter', name: 'Value', const: false }],
|
||||
})
|
||||
expect(() => emit([{
|
||||
id: 'root',
|
||||
kind: 'reference',
|
||||
name: 'Generic',
|
||||
target: { kind: 'declaration', symbol: 'Generic' },
|
||||
arguments: [],
|
||||
}], undefined, [generic])).toThrow('missing type argument Value')
|
||||
|
||||
const genericRoot = declaration('Root', 'interface', {
|
||||
typeParameters: [{ id: 'root:parameter', name: 'Value', const: false }],
|
||||
})
|
||||
expect(() => emit([], genericRoot)).toThrow('generic schema exports require a concrete declaration')
|
||||
})
|
||||
|
||||
it('rejects unsupported standard references and enums', () => {
|
||||
const intrinsic = { id: 'root', kind: 'keyword', name: 'intrinsic' } as unknown as TypeNodeModel
|
||||
expect(() => emit([intrinsic]))
|
||||
.toThrow('keyword intrinsic has no Zod projection')
|
||||
@@ -399,17 +568,6 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
arguments: [],
|
||||
}])).toThrow('standard type Promise has no Zod projection')
|
||||
|
||||
const generic = declaration('Generic', 'interface', {
|
||||
typeParameters: [{ id: 'parameter', name: 'Value', const: false }],
|
||||
})
|
||||
expect(() => emit([{
|
||||
id: 'root',
|
||||
kind: 'reference',
|
||||
name: 'Generic',
|
||||
target: { kind: 'declaration', symbol: 'Generic' },
|
||||
arguments: [],
|
||||
}], undefined, [generic])).toThrow('generic declarations require a schema-factory projection')
|
||||
|
||||
const enumeration = declaration('Enumeration', 'enum', {
|
||||
enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }],
|
||||
})
|
||||
@@ -481,6 +639,7 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
}],
|
||||
objects: [],
|
||||
schemas: [],
|
||||
invocations: [],
|
||||
}],
|
||||
}
|
||||
expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema'))
|
||||
@@ -513,6 +672,7 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
}],
|
||||
objects: [],
|
||||
schemas: [],
|
||||
invocations: [],
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -555,7 +715,33 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
expect(artifact.dts).toContain("from '@fixture/schema/secondary'")
|
||||
})
|
||||
|
||||
it.each(['method', 'getter', 'setter', 'call', 'construct', 'index'] as const)(
|
||||
it('emits JSON index signatures as record schemas', async () => {
|
||||
const root = declaration('Root', 'interface', {
|
||||
members: [indexMember('key', 'value')],
|
||||
})
|
||||
const schema = await loadSchema(emit([
|
||||
keyword('key', 'string'),
|
||||
keyword('value', 'number'),
|
||||
], root))
|
||||
|
||||
expect(schema.safeParse({ one: 1, two: 2 }).success).toBe(true)
|
||||
expect(schema.safeParse({ one: '1' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects more than one JSON index signature', () => {
|
||||
const root = declaration('Root', 'interface', {
|
||||
members: [indexMember('key', 'value'), indexMember('other-key', 'other-value')],
|
||||
})
|
||||
|
||||
expect(() => emit([
|
||||
keyword('key', 'string'),
|
||||
keyword('value', 'number'),
|
||||
keyword('other-key', 'string'),
|
||||
keyword('other-value', 'boolean'),
|
||||
], root)).toThrow('object type has more than one JSON index signature')
|
||||
})
|
||||
|
||||
it.each(['method', 'getter', 'setter', 'call', 'construct'] as const)(
|
||||
'rejects %s members on data-schema objects',
|
||||
(kind) => {
|
||||
expect(() => emit([
|
||||
@@ -608,6 +794,10 @@ function property(
|
||||
}
|
||||
}
|
||||
|
||||
function signatureMember(kind: 'index'): SignatureMemberModel
|
||||
function signatureMember(
|
||||
kind: Exclude<MemberModel['kind'], 'property' | 'index'>,
|
||||
): MemberModel
|
||||
function signatureMember(kind: Exclude<MemberModel['kind'], 'property'>): MemberModel {
|
||||
return {
|
||||
...documentation,
|
||||
@@ -626,6 +816,24 @@ function signatureMember(kind: Exclude<MemberModel['kind'], 'property'>): Member
|
||||
}
|
||||
}
|
||||
|
||||
function indexMember(key: string, value: string): SignatureMemberModel {
|
||||
return {
|
||||
...signatureMember('index'),
|
||||
signature: {
|
||||
typeParameters: [],
|
||||
parameters: [{
|
||||
name: 'key',
|
||||
binding: 'identifier',
|
||||
type: key,
|
||||
optional: false,
|
||||
rest: false,
|
||||
receiver: false,
|
||||
}],
|
||||
returns: value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function declaration(
|
||||
name: string,
|
||||
kind: TypeDeclarationModel['kind'],
|
||||
@@ -684,6 +892,7 @@ function emit(
|
||||
symbol: 'Root',
|
||||
type: 'schema-reference',
|
||||
}],
|
||||
invocations: [],
|
||||
}],
|
||||
}
|
||||
return new FaceModelEmitter(face).emit('@fixture/schema').js
|
||||
@@ -710,6 +919,7 @@ function schemaFace(
|
||||
symbol,
|
||||
type: 'root',
|
||||
}],
|
||||
invocations: [],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('model-driven dsh-tools generation', () => {
|
||||
TYPE_API.find(type => type.name === 'ToolDefinition'),
|
||||
)
|
||||
|
||||
dispose()
|
||||
await dispose()
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,11 @@ const generated = vi.hoisted(() => vi.fn(() => [
|
||||
exports: [],
|
||||
js: 'export const host = true\n',
|
||||
dts: 'export declare const host: true\n',
|
||||
remote: {
|
||||
js: 'export const remote = true\n',
|
||||
dts: 'export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n',
|
||||
dtsMap: '{"version":3}\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
package: '@deepseek-ai/dsh-tools',
|
||||
@@ -21,10 +26,30 @@ const generated = vi.hoisted(() => vi.fn(() => [
|
||||
js: 'export const client = true\n',
|
||||
dts: 'export declare const client: true\n',
|
||||
},
|
||||
{
|
||||
package: '@fixture/remote-only',
|
||||
packageRoot: 'packages/remote-only',
|
||||
face: 'host' as const,
|
||||
exports: [],
|
||||
js: 'export const local = true\n',
|
||||
dts: 'export declare const local: true\n',
|
||||
remote: {
|
||||
js: 'export const remoteOnly = true\n',
|
||||
dts: 'export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n',
|
||||
dtsMap: '{"version":3}\n',
|
||||
},
|
||||
},
|
||||
]))
|
||||
|
||||
const discovered = vi.hoisted(() => vi.fn(() => [
|
||||
{ package: '@deepseek-ai/dsh-tools', root: 'packages/core/tools', faces: ['host'] },
|
||||
{ package: '@fixture/ignored', root: 'packages/ignored', faces: ['host'] },
|
||||
{ package: '@fixture/remote-only', root: 'packages/remote-only', faces: ['host'] },
|
||||
]))
|
||||
|
||||
vi.mock('../src/workspace.ts', () => ({
|
||||
WorkspaceTypertGenerator: class {
|
||||
discover = discovered
|
||||
generate = generated
|
||||
},
|
||||
}))
|
||||
@@ -33,6 +58,7 @@ const { typertPlugin } = await import('../src/tsdown-plugin.ts')
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
discovered.mockClear()
|
||||
generated.mockClear()
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
@@ -80,9 +106,64 @@ describe('typertPlugin', () => {
|
||||
expect(readFileSync(join(packageLib, 'typert.host.d.ts'), 'utf8')).toBe('export declare const host: true\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.client.js'), 'utf8')).toBe('export const client = true\n')
|
||||
expect(existsSync(join(packageLib, 'typert.client.d.ts'))).toBe(true)
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')).toBe('export const remote = true\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8'))
|
||||
.toBe('export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8'))
|
||||
.toBe('{"version":3}\n')
|
||||
expect(readFileSync(join(root, 'packages/client-tools/lib/typert.client.js'), 'utf8'))
|
||||
.toBe('export const client = true\n')
|
||||
})
|
||||
|
||||
it('generates a package opted in only through its Remote export', async () => {
|
||||
const root = await workspace()
|
||||
const output = await packageOutput(root, 'remote-only', {
|
||||
name: '@fixture/remote-only',
|
||||
exports: { './remote': './lib/typert.remote-client.js' },
|
||||
})
|
||||
|
||||
typertPlugin().writeBundle({ dir: output })
|
||||
|
||||
const packageLib = join(root, 'packages', 'remote-only', 'lib')
|
||||
expect(generated).toHaveBeenCalledOnce()
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8'))
|
||||
.toBe('export const remoteOnly = true\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8'))
|
||||
.toBe('export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8'))
|
||||
.toBe('{"version":3}\n')
|
||||
})
|
||||
|
||||
it('emits every explicit workspace contributor once from a host-only prepass', async () => {
|
||||
const root = await workspace()
|
||||
const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' })
|
||||
await packageOutput(root, 'core/tools', {
|
||||
name: '@deepseek-ai/dsh-tools',
|
||||
exports: { './typert': './lib/typert.host.js' },
|
||||
})
|
||||
await packageOutput(root, 'ignored', { name: '@fixture/ignored' })
|
||||
await packageOutput(root, 'remote-only', {
|
||||
name: '@fixture/remote-only',
|
||||
exports: { './remote': './lib/typert.remote-client.js' },
|
||||
})
|
||||
|
||||
const plugin = typertPlugin({ mode: 'workspace', faces: ['host'] })
|
||||
plugin.writeBundle({ dir: trigger })
|
||||
plugin.writeBundle({ dir: join(root, 'packages/core/tools/lib/dev') })
|
||||
|
||||
expect(discovered).toHaveBeenCalledOnce()
|
||||
expect(discovered).toHaveBeenCalledWith(['host'])
|
||||
expect(generated).toHaveBeenCalledOnce()
|
||||
expect(generated).toHaveBeenCalledWith(
|
||||
['@deepseek-ai/dsh-tools', '@fixture/remote-only'],
|
||||
['host'],
|
||||
)
|
||||
expect(readFileSync(join(root, 'packages/core/tools/lib/typert.host.js'), 'utf8'))
|
||||
.toBe('export const host = true\n')
|
||||
expect(readFileSync(join(root, 'packages/remote-only/lib/typert.remote-client.js'), 'utf8'))
|
||||
.toBe('export const remoteOnly = true\n')
|
||||
expect(existsSync(join(root, 'packages/ignored/lib/typert.host.js'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
async function workspace(): Promise<string> {
|
||||
|
||||
@@ -201,6 +201,53 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
|
||||
expect(batched).toEqual(direct)
|
||||
})
|
||||
|
||||
it('discovers an explicitly keyed service implementation without a Context merge', () => {
|
||||
const root = copyFixture('explicit-service-')
|
||||
addExplicitServicePackage(root, 'service detached')
|
||||
const analyzer = new WorkspaceAnalyzer({ root })
|
||||
|
||||
expect(analyzer.discoverPackages()).toContainEqual({
|
||||
package: '@fixture/explicit-service',
|
||||
root: 'packages/explicit-service',
|
||||
faces: ['host'],
|
||||
})
|
||||
const model = new WorkspaceAnalyzer({ root, packages: ['@fixture/explicit-service'] }).analyze()
|
||||
const service = model.faces[0]?.packages[0]?.services[0]
|
||||
expect(service).toMatchObject({ key: 'detached', export: { name: 'DetachedService' } })
|
||||
})
|
||||
|
||||
it('prefers an explicitly keyed implementation over its protocol Context merge', () => {
|
||||
const root = copyFixture('explicit-service-protocol-')
|
||||
addExplicitServicePackage(root, 'service detached', true)
|
||||
const model = new WorkspaceAnalyzer({
|
||||
root,
|
||||
packages: ['@fixture/explicit-service'],
|
||||
}).analyze()
|
||||
const service = model.faces[0]?.packages[0]?.services[0]
|
||||
|
||||
expect(service).toMatchObject({
|
||||
key: 'detached',
|
||||
export: { name: 'DetachedService' },
|
||||
location: { file: 'packages/explicit-service/src/index.ts' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an explicit service implementation without one valid key', () => {
|
||||
const missing = copyFixture('explicit-service-missing-')
|
||||
addExplicitServicePackage(missing, 'service')
|
||||
expect(() => new WorkspaceAnalyzer({
|
||||
root: missing,
|
||||
packages: ['@fixture/explicit-service'],
|
||||
}).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key')
|
||||
|
||||
const invalid = copyFixture('explicit-service-invalid-')
|
||||
addExplicitServicePackage(invalid, 'service bad/key')
|
||||
expect(() => new WorkspaceAnalyzer({
|
||||
root: invalid,
|
||||
packages: ['@fixture/explicit-service'],
|
||||
}).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key')
|
||||
})
|
||||
|
||||
it('indexes authored top-level exports without promoting them to graph roots', () => {
|
||||
const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations()
|
||||
const agent = declarations.find(declaration => declaration.name === 'Agent')
|
||||
@@ -1178,6 +1225,57 @@ function addSameFacePackage(root: string, specifier: string, importedName: strin
|
||||
writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function addExplicitServicePackage(root: string, annotation: string, withProtocol = false): void {
|
||||
const packageRoot = join(root, 'packages/explicit-service')
|
||||
mkdirSync(join(packageRoot, 'src'), { recursive: true })
|
||||
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
|
||||
name: '@fixture/explicit-service',
|
||||
private: true,
|
||||
type: 'module',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './lib/types/index.d.ts',
|
||||
default: './lib/index.js',
|
||||
},
|
||||
},
|
||||
}, null, 2))
|
||||
writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({
|
||||
extends: '../../tsconfig.base.json',
|
||||
compilerOptions: { rootDir: 'src', outDir: 'lib/types' },
|
||||
include: ['src'],
|
||||
}, null, 2))
|
||||
if (withProtocol) {
|
||||
writeFileSync(join(packageRoot, 'src/types.ts'), [
|
||||
'/** Public detached Service protocol. */',
|
||||
'export interface DetachedProtocol {',
|
||||
' /** Report protocol readiness. */',
|
||||
' ready(): boolean',
|
||||
'}',
|
||||
"declare module 'cordis' {",
|
||||
' interface Context { detached: DetachedProtocol }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
writeFileSync(join(packageRoot, 'src/index.ts'), [
|
||||
"import { Service } from 'cordis'",
|
||||
...(withProtocol ? ["export type { DetachedProtocol } from './types.ts'"] : []),
|
||||
'/**',
|
||||
' * Service implementation discovered independently of its protocol package.',
|
||||
` * @typert ${annotation}`,
|
||||
' */',
|
||||
'export class DetachedService extends Service {',
|
||||
' /** Report readiness. */',
|
||||
' ready(): boolean { return true }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const aggregatePath = join(root, 'tsconfig.host.json')
|
||||
const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] }
|
||||
aggregate.references.push({ path: './packages/explicit-service' })
|
||||
writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`)
|
||||
}
|
||||
|
||||
describe('FaceModelEmitter', { timeout: 60_000 }, () => {
|
||||
it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => {
|
||||
const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze()
|
||||
|
||||
@@ -135,6 +135,11 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type
|
||||
requireMembers(pkgName, object.members, `object "${object.name as string}"`)
|
||||
requireTypes(pkgName, object.types, `object "${object.name as string}"`)
|
||||
}
|
||||
if (manifest.invocations !== undefined) {
|
||||
for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) {
|
||||
requireInvocation(pkgName, value)
|
||||
}
|
||||
}
|
||||
return manifest as unknown as TypertContribution
|
||||
}
|
||||
|
||||
@@ -184,6 +189,88 @@ function requireTypes(pkgName: string, value: unknown, subject: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function requireInvocation(pkgName: string, value: unknown): void {
|
||||
const invocation = requireObject(pkgName, value, 'invocation')
|
||||
for (const key of ['id', 'service', 'namespace', 'method'] as const) {
|
||||
requireString(pkgName, invocation, key, 'invocation')
|
||||
}
|
||||
const id = invocation.id as string
|
||||
const receiver = requireObject(pkgName, invocation.invocation, `invocation "${id}" receiver`)
|
||||
if (receiver.kind === 'context') {
|
||||
requireString(pkgName, receiver, 'context', `invocation "${id}" Context receiver`)
|
||||
requireString(pkgName, receiver, 'wire', `invocation "${id}" Context receiver`)
|
||||
requireStrictCodec(pkgName, receiver.codec, `invocation "${id}" Context codec`)
|
||||
} else if (receiver.kind !== 'direct') {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" receiver kind must be "direct" or "context"`)
|
||||
}
|
||||
const wires = new Set<string>()
|
||||
const parameters = new Map<string, Record<string, unknown>>()
|
||||
let lookupCount = 0
|
||||
for (const valueParameter of requireArray(pkgName, invocation.parameters, `invocation "${id}" parameters`)) {
|
||||
const parameter = requireObject(pkgName, valueParameter, `invocation "${id}" parameter`)
|
||||
requireString(pkgName, parameter, 'name', `invocation "${id}" parameter`)
|
||||
requireString(pkgName, parameter, 'wire', `invocation "${id}" parameter`)
|
||||
const wire = parameter.wire as string
|
||||
if (wires.has(wire)) {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats wire field "${wire}"`)
|
||||
}
|
||||
wires.add(wire)
|
||||
if (parameter.source === 'lookup') {
|
||||
lookupCount += 1
|
||||
requireString(pkgName, parameter, 'lookup', `invocation "${id}" lookup parameter`)
|
||||
} else if (parameter.source === 'json') {
|
||||
if (parameter.lookup !== undefined) {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" JSON parameter declares a lookup`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" parameter source must be "json" or "lookup"`)
|
||||
}
|
||||
parameters.set(wire, parameter)
|
||||
requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`)
|
||||
}
|
||||
if (invocation.scope !== undefined) {
|
||||
if (receiver.kind !== 'direct') {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`)
|
||||
}
|
||||
const scope = requireObject(pkgName, invocation.scope, `invocation "${id}" scope`)
|
||||
requireString(pkgName, scope, 'context', `invocation "${id}" scope`)
|
||||
requireString(pkgName, scope, 'wire', `invocation "${id}" scope`)
|
||||
const parameter = parameters.get(scope.wire as string)
|
||||
if (lookupCount !== 1 || parameter?.source !== 'lookup' || parameter.lookup !== scope.context) {
|
||||
throw new Error(
|
||||
`typert-loader: ${pkgName} invocation "${id}" scope wire "${scope.wire as string}" must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (receiver.kind === 'context' && wires.has(receiver.wire as string)) {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats Context wire field "${receiver.wire as string}"`)
|
||||
}
|
||||
requireStrictCodec(pkgName, invocation.result, `invocation "${id}" result codec`)
|
||||
if (invocation.sourceLocation !== undefined) {
|
||||
const location = requireObject(pkgName, invocation.sourceLocation, `invocation "${id}" sourceLocation`)
|
||||
requireString(pkgName, location, 'file', `invocation "${id}" sourceLocation`)
|
||||
for (const key of ['line', 'column'] as const) {
|
||||
if (!Number.isInteger(location[key]) || (location[key] as number) < 1) {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" sourceLocation.${key} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictCodec(pkgName: string, value: unknown, subject: string): void {
|
||||
const codec = requireObject(pkgName, value, subject)
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`)
|
||||
}
|
||||
requireString(pkgName, codec, 'typeSymbol', subject)
|
||||
if (typeof codec.schema !== 'object'
|
||||
|| codec.schema === null
|
||||
|| !('_zod' in codec.schema)
|
||||
|| typeof (codec.schema as { parse?: unknown }).parse !== 'function') {
|
||||
throw new Error(`typert-loader: ${pkgName} ${subject} is not backed by a zod v4 schema`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan current Loader entries during activation, then follow entry mounts and
|
||||
* unmounts for this plugin's lifetime.
|
||||
@@ -202,7 +289,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const configured = new Set((config as ResolvedConfig).packages)
|
||||
|
||||
// Registered contributions by entry name; the disposer withdraws the entry's registration.
|
||||
const registered = new Map<string, () => void>()
|
||||
const registered = new Map<string, () => Promise<void>>()
|
||||
// In-flight import/register tasks by entry name.
|
||||
const pending = new Map<string, Promise<void>>()
|
||||
// Artifact paths by package name. Negative verdicts (unresolvable specifier —
|
||||
@@ -279,7 +366,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const dispose = registered.get(entryName)
|
||||
if (dispose !== undefined) {
|
||||
registered.delete(entryName)
|
||||
dispose()
|
||||
return dispose()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -8,6 +9,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as typertLoader from '@deepseek-ai/dsh-typert-loader'
|
||||
import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader'
|
||||
import { z } from 'zod'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
@@ -63,12 +65,45 @@ function typertSource(pkgName: string, entryName: string): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function invocationTypertSource(pkgName: string): string {
|
||||
return [
|
||||
'import { z } from \'zod\'',
|
||||
'const Text = z.string()',
|
||||
'export const TYPERT = {',
|
||||
` package: '${pkgName}',`,
|
||||
' face: \'host\',',
|
||||
' schemas: [],',
|
||||
' model: { services: [], events: [], objects: [] },',
|
||||
' invocations: [{',
|
||||
` id: '${pkgName}#goals/create',`,
|
||||
' service: \'goals\', namespace: \'goals\', method: \'create\',',
|
||||
' invocation: { kind: \'direct\' },',
|
||||
' parameters: [{',
|
||||
' name: \'request\', wire: \'request\', source: \'json\',',
|
||||
` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`,
|
||||
' }],',
|
||||
` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`,
|
||||
' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },',
|
||||
' }],',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */
|
||||
async function boot(): Promise<Context> {
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href
|
||||
await context.plugin(TypertRegistry)
|
||||
await context.plugin(Loader)
|
||||
const fixtureRequire = createRequire(context.baseUrl)
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
const module: unknown = await import(pathToFileURL(fixtureRequire.resolve(specifier)).href)
|
||||
return module
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
// zod must be resolvable from the fixture packages; link the workspace copy.
|
||||
await mkdir(join(root as string, 'node_modules'), { recursive: true })
|
||||
return context
|
||||
@@ -105,6 +140,33 @@ describe('typert loader', () => {
|
||||
expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registers a strict invocation into the local registry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
|
||||
await linkZod(root)
|
||||
await writePackage(root, '@fixture/invocation', {
|
||||
typertSource: invocationTypertSource('@fixture/invocation'),
|
||||
})
|
||||
const ctx = await boot()
|
||||
|
||||
const fiber = mountTypertLoader(ctx, { packages: ['@fixture/invocation'] })
|
||||
await fiber
|
||||
|
||||
const descriptor = ctx.typert.local.get('goals/create')
|
||||
expect(descriptor).toMatchObject({
|
||||
id: '@fixture/invocation#goals/create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{ wire: 'request', source: 'json' }],
|
||||
sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },
|
||||
})
|
||||
expect(descriptor?.parameters[0]?.codec.mode).toBe('strict')
|
||||
if (descriptor?.parameters[0]?.codec.mode === 'strict') {
|
||||
expect(descriptor.parameters[0].codec.schema.parse('request')).toBe('request')
|
||||
}
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.typert.local.get('goals/create')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud when an explicit package is absent or has no Typert export', LOADER_TEST_TIMEOUT, async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
|
||||
await writePackage(root, '@fixture/plain')
|
||||
@@ -427,8 +489,156 @@ describe('validateTypertManifest', () => {
|
||||
model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] },
|
||||
})).toThrow('object has a missing or empty exportName')
|
||||
})
|
||||
|
||||
it('validates strict invocation descriptors and accepts legacy manifests without them', () => {
|
||||
const legacy = completeManifest(zodish)
|
||||
expect(validateTypertManifest('pkg', legacy)).toBe(legacy)
|
||||
|
||||
const descriptor = strictInvocation()
|
||||
const manifest = { ...legacy, invocations: [descriptor] }
|
||||
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
|
||||
const scoped = {
|
||||
...descriptor,
|
||||
scope: { context: 'agent', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'agent',
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
}, ...descriptor.parameters],
|
||||
}
|
||||
expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations)
|
||||
.toEqual([scoped])
|
||||
|
||||
expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} }))
|
||||
.toThrow('TYPERT.invocations must be an array')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...descriptor, invocation: { kind: 'future' } }],
|
||||
})).toThrow('receiver kind must be "direct" or "context"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...descriptor, result: { mode: 'src-json' } }],
|
||||
})).toThrow('result codec must use a strict codec')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }],
|
||||
})).toThrow('result codec is not backed by a zod v4 schema')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], source: 'future' }],
|
||||
}],
|
||||
})).toThrow('parameter source must be "json" or "lookup"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], source: 'lookup' }],
|
||||
}],
|
||||
})).toThrow('lookup parameter has a missing or empty lookup')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }],
|
||||
}],
|
||||
})).toThrow('JSON parameter declares a lookup')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }],
|
||||
}],
|
||||
})).toThrow('repeats wire field "request"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'agent',
|
||||
wire: 'request',
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
},
|
||||
}],
|
||||
})).toThrow('repeats Context wire field "request"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: null }],
|
||||
})).toThrow('scope must be an object')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: { wire: 'agentId' } }],
|
||||
})).toThrow('scope has a missing or empty context')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: { context: 'agent' } }],
|
||||
})).toThrow('scope has a missing or empty wire')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...scoped,
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'agent',
|
||||
wire: 'scopeId',
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
},
|
||||
}],
|
||||
})).toThrow('Context receiver cannot declare a direct scope projection')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...scoped,
|
||||
parameters: [...scoped.parameters, {
|
||||
name: 'other',
|
||||
wire: 'otherId',
|
||||
source: 'lookup',
|
||||
lookup: 'agent',
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
}],
|
||||
}],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }],
|
||||
})).toThrow('sourceLocation.line must be a positive integer')
|
||||
})
|
||||
})
|
||||
|
||||
function strictCodec(typeSymbol: string) {
|
||||
return { mode: 'strict', typeSymbol, schema: z.string() }
|
||||
}
|
||||
|
||||
function strictInvocation() {
|
||||
return {
|
||||
id: 'pkg#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: strictCodec('pkg#Request'),
|
||||
}],
|
||||
result: strictCodec('pkg#Result'),
|
||||
sourceLocation: { file: 'src/index.ts', line: 1, column: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function completeManifest(zodish: object) {
|
||||
const member = { name: 'member', signature: 'member(): void', kind: 'method' }
|
||||
const type = { name: 'Value', declaration: 'export interface Value {}' }
|
||||
|
||||
@@ -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'])
|
||||
|
||||
6
packages/typert/type-meta/README.i18n.yaml
Normal file
6
packages/typert/type-meta/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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: 9dd8dadd07b219c7471c8851262958d4d9e96a43
|
||||
README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e
|
||||
33
packages/typert/type-meta/README.md
Normal file
33
packages/typert/type-meta/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-type-meta
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service.
|
||||
|
||||
## Remote declarations
|
||||
|
||||
- `@Remote` marks a public instance method for direct invocation on its registered Cordis Service.
|
||||
- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind.
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace.
|
||||
- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback.
|
||||
|
||||
Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field.
|
||||
|
||||
## TypeRT protocol
|
||||
|
||||
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`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this protocol package declares application reflection and registers no model surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Decorator markers contain only the method name and direct or Context invocation mode. Parameter, result, lookup, and schema reflection require the TypeRT build pipeline.
|
||||
- Remote decorators accept only public, non-static instance methods with string names. SRC execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures.
|
||||
33
packages/typert/type-meta/README.zh.md
Normal file
33
packages/typert/type-meta/README.zh.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-type-meta
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。
|
||||
|
||||
## Remote 声明
|
||||
|
||||
- `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。
|
||||
- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。
|
||||
- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。
|
||||
|
||||
装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。
|
||||
|
||||
## TypeRT 协议
|
||||
|
||||
业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。
|
||||
|
||||
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该协议包声明应用反射,不注册任何模型接口。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- 装饰器标记仅包含方法名,以及直接调用或 Context 调用模式。参数、结果、查找和 schema 反射需要 TypeRT 构建流水线。
|
||||
- Remote 装饰器只接受具有字符串名称的公开、非静态实例方法。SRC 执行无法表示重载签名,以及包含解构参数、默认参数或剩余参数的方法签名。
|
||||
42
packages/typert/type-meta/package.json
Normal file
42
packages/typert/type-meta/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-type-meta",
|
||||
"description": "Compiler-independent Remote metadata and TypeRT provider protocols",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
223
packages/typert/type-meta/src/index.ts
Normal file
223
packages/typert/type-meta/src/index.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Remote decorators and explicit Gateway bindings backed only by private
|
||||
* module state. Strict reflection remains a TypeRT compiler responsibility.
|
||||
* @module @deepseek-ai/dsh-type-meta
|
||||
*/
|
||||
|
||||
import type { TypeRTContextMap } from './types.ts'
|
||||
|
||||
export type {
|
||||
InvocationDescriptor,
|
||||
InvocationParameterDescriptor,
|
||||
InvocationSourceLocation,
|
||||
TypeRTClientContextBinder,
|
||||
TypeRTCodec,
|
||||
TypeRTContext,
|
||||
TypeRTContextMap,
|
||||
TypeRTContextRegistry,
|
||||
TypeRTContextWire,
|
||||
TypeRTDisposer,
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTLocalRegistry,
|
||||
TypeRTLookup,
|
||||
TypeRTLookupHost,
|
||||
TypeRTLookupMap,
|
||||
TypeRTLookupProvider,
|
||||
TypeRTLookupRegistry,
|
||||
TypeRTLookupWire,
|
||||
TypeRTRemoteContextApi,
|
||||
TypeRTRemoteContextMap,
|
||||
TypeRTRemoteContextNamespace,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteMap,
|
||||
TypeRTRemoteNamespace,
|
||||
TypeRTRemoteNamespaceMap,
|
||||
TypeRTRemoteRegistry,
|
||||
TypeRTRegistryChange,
|
||||
TypeRTRegistryListener,
|
||||
TypeRTSchema,
|
||||
TypeRTService,
|
||||
} from './types.ts'
|
||||
|
||||
/** Options for an explicit Service-to-Gateway binding. */
|
||||
export interface TypeRTGatewayBindingOptions {
|
||||
/** Wire namespace; defaults to the Cordis service key. */
|
||||
readonly namespace?: string
|
||||
}
|
||||
|
||||
/** Visible declaration that one Service participates in TypeRT Gateway export. */
|
||||
export interface TypeRTGatewayBinding<Service extends object = object> {
|
||||
readonly service: Service
|
||||
readonly serviceKey: string
|
||||
readonly namespace: string
|
||||
}
|
||||
|
||||
/** Invocation mode recorded by a Remote method decorator. */
|
||||
export type RemoteInvocationMarker =
|
||||
| { readonly kind: 'direct' }
|
||||
| { readonly kind: 'context'; readonly context: string }
|
||||
|
||||
/** One decorator marker discovered for a live Service instance. */
|
||||
export interface RemoteMethodMarker {
|
||||
/** Public instance method carrying the implementation. */
|
||||
readonly method: string
|
||||
/** Endpoint method when it differs from the implementation member. */
|
||||
readonly exportName?: string
|
||||
readonly invocation: RemoteInvocationMarker
|
||||
}
|
||||
|
||||
type RemoteMethodDecorator = <This extends object, Args extends unknown[], Result>(
|
||||
method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
) => void
|
||||
|
||||
interface RemoteInitializerContext<This extends object> {
|
||||
readonly private: boolean
|
||||
readonly static: boolean
|
||||
readonly name: string | symbol
|
||||
addInitializer(initializer: (this: This) => void): void
|
||||
}
|
||||
|
||||
interface StoredRemoteMethodMarker {
|
||||
readonly exportName?: string
|
||||
readonly invocation: RemoteInvocationMarker
|
||||
}
|
||||
|
||||
const markers = new WeakMap<object, Map<string, StoredRemoteMethodMarker>>()
|
||||
|
||||
/**
|
||||
* Bind one visible Service field to a Cordis key and Remote namespace.
|
||||
* @param service - owning Service instance, normally `this`.
|
||||
* @param serviceKey - exact Cordis service key.
|
||||
* @param options - optional distinct wire namespace.
|
||||
* @returns a frozen, inspectable binding with no compiler-injected metadata.
|
||||
*/
|
||||
export function bindTypeRTGateway<Service extends object>(
|
||||
service: Service,
|
||||
serviceKey: string,
|
||||
options: TypeRTGatewayBindingOptions = {},
|
||||
): TypeRTGatewayBinding<Service> {
|
||||
validateName('service key', serviceKey)
|
||||
const namespace = options.namespace ?? serviceKey
|
||||
validateName('namespace', namespace)
|
||||
return Object.freeze({ service, serviceKey, namespace })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one public instance method as a direct Remote invocation.
|
||||
* @param _method - decorated method; retained only by the class itself.
|
||||
* @param context - standard decorator context used to schedule private marking.
|
||||
*/
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
_method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void
|
||||
/**
|
||||
* Mark one public instance method under a distinct exported method name.
|
||||
* @param exportName - Remote endpoint method, without a namespace or slash.
|
||||
* @returns a standard method decorator.
|
||||
*/
|
||||
export function Remote(exportName: string): RemoteMethodDecorator
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
methodOrExportName: string | ((this: This, ...args: Args) => Result),
|
||||
context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void | RemoteMethodDecorator {
|
||||
if (typeof methodOrExportName === 'string') {
|
||||
validateName('Remote export name', methodOrExportName)
|
||||
return function <DecoratorThis extends object, DecoratorArgs extends unknown[], DecoratorResult>(
|
||||
_method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult,
|
||||
decoratorContext: ClassMethodDecoratorContext<
|
||||
DecoratorThis,
|
||||
(this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult
|
||||
>,
|
||||
): void {
|
||||
addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName)
|
||||
}
|
||||
}
|
||||
if (context === undefined) throw new TypeError('type-meta: Remote decorator context is missing')
|
||||
addMarkerInitializer(context, { kind: 'direct' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a decorator for a method resolved from one scoped Remote Context.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param exportName - optional Remote export name; defaults to the method name.
|
||||
* @returns a standard method decorator that records only private module state.
|
||||
*/
|
||||
export function RemoteContext(
|
||||
key: Extract<keyof TypeRTContextMap, string>,
|
||||
exportName?: string,
|
||||
): RemoteMethodDecorator {
|
||||
validateName('Context key', key)
|
||||
if (exportName !== undefined) validateName('Remote export name', exportName)
|
||||
return function <This extends object, Args extends unknown[], Result>(
|
||||
_method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void {
|
||||
addMarkerInitializer(context, { kind: 'context', context: key }, exportName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Remote markers attached to a live Service by decorator initializers.
|
||||
* The returned snapshot cannot mutate the private marker table.
|
||||
* @param service - live Service instance.
|
||||
* @returns markers in class declaration order.
|
||||
*/
|
||||
export function remoteMethods(service: object): readonly RemoteMethodMarker[] {
|
||||
const prototype = Object.getPrototypeOf(service) as object | null
|
||||
if (prototype === null) return []
|
||||
return [...(markers.get(prototype) ?? [])].map(([method, marker]) => ({ method, ...marker }))
|
||||
}
|
||||
|
||||
function addMarkerInitializer<This extends object>(
|
||||
context: RemoteInitializerContext<This>,
|
||||
invocation: RemoteInvocationMarker,
|
||||
exportName?: string,
|
||||
): void {
|
||||
if (context.private || context.static || typeof context.name !== 'string') {
|
||||
throw new TypeError('type-meta: Remote decorators require a public instance method with a string name')
|
||||
}
|
||||
const method = context.name
|
||||
context.addInitializer(function (this: This) {
|
||||
const prototype = Object.getPrototypeOf(this) as object | null
|
||||
if (prototype === null) {
|
||||
throw new TypeError(`type-meta: cannot mark Remote method "${method}" on an object without a prototype`)
|
||||
}
|
||||
mark(prototype, method, invocation, exportName)
|
||||
})
|
||||
}
|
||||
|
||||
function mark(
|
||||
prototype: object,
|
||||
method: string,
|
||||
invocation: RemoteInvocationMarker,
|
||||
exportName?: string,
|
||||
): void {
|
||||
let table = markers.get(prototype)
|
||||
if (table === undefined) {
|
||||
table = new Map()
|
||||
markers.set(prototype, table)
|
||||
}
|
||||
const marker: StoredRemoteMethodMarker = {
|
||||
...(exportName === undefined || exportName === method ? {} : { exportName }),
|
||||
invocation: Object.freeze(invocation),
|
||||
}
|
||||
const current = table.get(method)
|
||||
if (current !== undefined) {
|
||||
if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return
|
||||
throw new Error(`type-meta: Remote method "${method}" has conflicting invocation markers`)
|
||||
}
|
||||
table.set(method, Object.freeze(marker))
|
||||
}
|
||||
|
||||
function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMarker): boolean {
|
||||
return left.kind === right.kind
|
||||
&& (left.kind === 'direct' || (right.kind === 'context' && left.context === right.context))
|
||||
}
|
||||
|
||||
function validateName(subject: string, value: string): void {
|
||||
if (value.length === 0 || value.includes('/')) {
|
||||
throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`)
|
||||
}
|
||||
}
|
||||
30
packages/typert/type-meta/src/invariant.ts
Normal file
30
packages/typert/type-meta/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-type-meta`.
|
||||
* @module @deepseek-ai/dsh-type-meta/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'type-meta-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: decorators retain private immutable declarations and
|
||||
* bindings are frozen values with no independent event stream to cross-check.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
358
packages/typert/type-meta/src/types.ts
Normal file
358
packages/typert/type-meta/src/types.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Compiler-independent TypeRT protocol shared by business packages, generated
|
||||
* Remote artifacts, the Host Gateway, and Client API implementations.
|
||||
* @module @deepseek-ai/dsh-type-meta/types
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare const LOOKUP_HOST: unique symbol
|
||||
declare const LOOKUP_WIRE: unique symbol
|
||||
declare const CONTEXT_WIRE: unique symbol
|
||||
|
||||
/** Type-level association between a Host object and its wire identity. */
|
||||
export interface TypeRTLookup<Host, Wire> {
|
||||
readonly [LOOKUP_HOST]: Host
|
||||
readonly [LOOKUP_WIRE]: Wire
|
||||
}
|
||||
|
||||
/** Extract the Host object associated with one lookup declaration. */
|
||||
export type TypeRTLookupHost<Lookup> = Lookup extends TypeRTLookup<infer Host, infer _Wire> ? Host : never
|
||||
|
||||
/** Extract the wire identity associated with one lookup declaration. */
|
||||
export type TypeRTLookupWire<Lookup> = Lookup extends TypeRTLookup<infer _Host, infer Wire> ? Wire : never
|
||||
|
||||
/** Type-level association between a scoped Context kind and its wire identity. */
|
||||
export interface TypeRTContext<Wire> {
|
||||
readonly [CONTEXT_WIRE]: Wire
|
||||
}
|
||||
|
||||
/** Extract the wire identity associated with one scoped Context declaration. */
|
||||
export type TypeRTContextWire<ContextType> = ContextType extends TypeRTContext<infer Wire> ? Wire : never
|
||||
|
||||
/** Merge-extensible Host object lookup declarations. */
|
||||
export interface TypeRTLookupMap {}
|
||||
|
||||
/** Merge-extensible scoped Context declarations. */
|
||||
export interface TypeRTContextMap {}
|
||||
|
||||
/** Merge-extensible direct Remote method signatures generated for consumers. */
|
||||
export interface TypeRTRemoteMap {}
|
||||
|
||||
/** Merge-extensible scoped Remote method signatures generated for consumers. */
|
||||
export interface TypeRTRemoteContextMap {}
|
||||
|
||||
/**
|
||||
* Resolve one direct Remote namespace from the generated flat endpoint map.
|
||||
* @template Namespace - wire namespace before the endpoint slash.
|
||||
*/
|
||||
export type TypeRTRemoteNamespace<Namespace extends string> = {
|
||||
[Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}`
|
||||
? Method
|
||||
: never]: TypeRTRemoteMap[Endpoint]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one scoped Remote namespace across every generated Context kind.
|
||||
* The calling Cordis Context supplies the concrete identity at runtime.
|
||||
* @template Namespace - wire namespace between the Context prefix and method.
|
||||
*/
|
||||
export type TypeRTRemoteContextNamespace<
|
||||
Namespace extends string,
|
||||
ContextKey extends string = string,
|
||||
> = {
|
||||
[Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}`
|
||||
? Method
|
||||
: never]: TypeRTRemoteContextMap[Endpoint]
|
||||
}
|
||||
|
||||
type TypeRTRemoteContextNamespaceKey<
|
||||
ContextKey extends string,
|
||||
Endpoint = keyof TypeRTRemoteContextMap,
|
||||
> = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never
|
||||
|
||||
/** Generated scoped Remote namespaces available to one Context kind. */
|
||||
export type TypeRTRemoteContextApi<ContextKey extends string> = {
|
||||
[Namespace in TypeRTRemoteContextNamespaceKey<ContextKey>]:
|
||||
TypeRTRemoteContextNamespace<Namespace, ContextKey>
|
||||
}
|
||||
|
||||
/** Merge-extensible direct namespace surface generated for Client API services. */
|
||||
export interface TypeRTRemoteNamespaceMap {}
|
||||
|
||||
/** Awaitable disposer returned by Cordis-owned TypeRT registrations. */
|
||||
export type TypeRTDisposer = () => Promise<void>
|
||||
|
||||
type StringKeyOf<Value> = Extract<keyof Value, string>
|
||||
|
||||
/** Minimal runtime-schema capability carried by strict generated codecs. */
|
||||
export interface TypeRTSchema<Output = unknown> {
|
||||
/**
|
||||
* Parse and validate one boundary value.
|
||||
* @param value - untrusted boundary value.
|
||||
* @returns the validated value.
|
||||
*/
|
||||
parse(value: unknown): Output
|
||||
}
|
||||
|
||||
/** Codec attached to one invocation parameter or result. */
|
||||
export type TypeRTCodec =
|
||||
| {
|
||||
readonly mode: 'strict'
|
||||
readonly typeSymbol: string
|
||||
readonly schema: TypeRTSchema
|
||||
}
|
||||
| {
|
||||
readonly mode: 'src-json'
|
||||
}
|
||||
|
||||
/** One ordered business parameter in a Remote invocation. */
|
||||
export interface InvocationParameterDescriptor {
|
||||
/** Source-level parameter name. */
|
||||
readonly name: string
|
||||
/** Required key in the wire `args` object. */
|
||||
readonly wire: string
|
||||
/** Whether the value is JSON or requires a registered Host lookup. */
|
||||
readonly source: 'json' | 'lookup'
|
||||
/** Lookup key when `source` is `lookup`. */
|
||||
readonly lookup?: string
|
||||
/** Boundary codec for the wire representation. */
|
||||
readonly codec: TypeRTCodec
|
||||
}
|
||||
|
||||
/** Source position retained for diagnostics from generated definitions. */
|
||||
export interface InvocationSourceLocation {
|
||||
readonly file: string
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
}
|
||||
|
||||
/** Carrier-independent description of one exported method invocation. */
|
||||
export interface InvocationDescriptor {
|
||||
/** Globally stable generated identity. */
|
||||
readonly id: string
|
||||
/** Cordis service key owning the method. */
|
||||
readonly service: string
|
||||
/** Wire namespace, defaulting to the service key. */
|
||||
readonly namespace: string
|
||||
/** Public instance method name. */
|
||||
readonly method: string
|
||||
/** Service member invoked when the exported method name is an alias. */
|
||||
readonly implementation?: string
|
||||
/** Receiver selection mode. */
|
||||
readonly invocation:
|
||||
| { readonly kind: 'direct' }
|
||||
| {
|
||||
readonly kind: 'context'
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly codec: TypeRTCodec
|
||||
}
|
||||
/** Optional consuming-Context projection for one direct lookup parameter. */
|
||||
readonly scope?: {
|
||||
/** Context kind whose Client binder supplies the identity. */
|
||||
readonly context: string
|
||||
/** Lookup parameter wire field replaced by the Context identity. */
|
||||
readonly wire: string
|
||||
}
|
||||
/** Ordered business parameters. */
|
||||
readonly parameters: readonly InvocationParameterDescriptor[]
|
||||
/** Codec for the resolved method result. */
|
||||
readonly result: TypeRTCodec
|
||||
/** Source declaration used only for diagnostics. */
|
||||
readonly sourceLocation?: InvocationSourceLocation
|
||||
}
|
||||
|
||||
/** Generated Host contract selected explicitly by a Client assembly. */
|
||||
export interface TypeRTRemoteContribution {
|
||||
/** npm package that owns the Remote methods. */
|
||||
readonly package: string
|
||||
/** Consumer-side invocation descriptors generated from that package. */
|
||||
readonly descriptors: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
/** Runtime resolver for one declared Host object lookup. */
|
||||
export interface TypeRTLookupProvider<Host = unknown, Wire = unknown> {
|
||||
/** Source parameter name recognized by the SRC weak parser. */
|
||||
readonly parameter: string
|
||||
/** Wire field replacing the Host object parameter. */
|
||||
readonly wire: string
|
||||
/** Canonical Host type symbol used by strict generation. */
|
||||
readonly hostTypeSymbol: string
|
||||
/** Canonical wire type symbol used by strict generation. */
|
||||
readonly wireTypeSymbol: string
|
||||
/**
|
||||
* Resolve a wire identity to the current live Host object.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the live object, or `undefined` when it is unavailable.
|
||||
*/
|
||||
resolve(id: Wire): Host | undefined
|
||||
}
|
||||
|
||||
/** Host resolver for one scoped Remote Context kind. */
|
||||
export interface TypeRTHostContextProvider<Wire = unknown> {
|
||||
/** Wire field carrying the Context identity. */
|
||||
readonly wire: string
|
||||
/** Canonical wire type symbol used by strict generation. */
|
||||
readonly wireTypeSymbol: string
|
||||
/**
|
||||
* Resolve a wire identity to its live scoped Context.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the scoped Context, or `undefined` when unavailable.
|
||||
*/
|
||||
resolve(id: Wire): Context | undefined
|
||||
}
|
||||
|
||||
/** Client resolver for the identity carried by the calling scoped Context. */
|
||||
export interface TypeRTClientContextBinder<Wire = unknown> {
|
||||
/**
|
||||
* Read the Remote identity represented by a calling Context.
|
||||
* @param ctx - Context rebound by the Cordis service tracker.
|
||||
* @returns the wire identity, or `undefined` when the Context has the wrong scope.
|
||||
*/
|
||||
identity(ctx: Context): Wire | undefined
|
||||
}
|
||||
|
||||
/** Notification emitted after a TypeRT runtime registry changes. */
|
||||
export interface TypeRTRegistryChange {
|
||||
readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context'
|
||||
readonly key: string
|
||||
}
|
||||
|
||||
/** Listener for one TypeRT runtime registry. */
|
||||
export type TypeRTRegistryListener = (change: TypeRTRegistryChange) => void
|
||||
|
||||
/** Current-environment invocation definitions. */
|
||||
export interface TypeRTLocalRegistry {
|
||||
/**
|
||||
* Look up one invocation by `<namespace>/<method>`.
|
||||
* @param endpoint - canonical endpoint.
|
||||
* @returns the live descriptor, or `undefined` when absent.
|
||||
*/
|
||||
get(endpoint: string): InvocationDescriptor | undefined
|
||||
/**
|
||||
* Report whether a strict definition has existed during this TypeRT Service lifetime.
|
||||
* @param endpoint - canonical endpoint.
|
||||
* @returns `true` after the endpoint has been registered at least once, even if withdrawn.
|
||||
*/
|
||||
hasSeen(endpoint: string): boolean
|
||||
/** @returns a registration-order snapshot of local descriptors. */
|
||||
list(): readonly InvocationDescriptor[]
|
||||
/**
|
||||
* Observe later local-definition changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Consumer-selected Remote contribution registry. */
|
||||
export interface TypeRTRemoteRegistry {
|
||||
/**
|
||||
* Register one generated contribution for the calling Cordis fiber.
|
||||
* @param contribution - generated Remote descriptors.
|
||||
* @returns disposer withdrawing the exact contribution.
|
||||
*/
|
||||
register(contribution: TypeRTRemoteContribution): TypeRTDisposer
|
||||
/**
|
||||
* Look up one Remote descriptor by endpoint.
|
||||
* @param endpoint - canonical endpoint.
|
||||
* @returns the descriptor, or `undefined` when unmounted.
|
||||
*/
|
||||
get(endpoint: string): InvocationDescriptor | undefined
|
||||
/** @returns a registration-order snapshot of Remote descriptors. */
|
||||
list(): readonly InvocationDescriptor[]
|
||||
/**
|
||||
* Observe later Remote contribution changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Runtime registry for Host object lookup providers. */
|
||||
export interface TypeRTLookupRegistry {
|
||||
/**
|
||||
* Register one provider under its merge-declared key.
|
||||
* @param key - lookup key.
|
||||
* @param provider - owning package's live resolver.
|
||||
* @returns disposer withdrawing the exact provider.
|
||||
*/
|
||||
register<K extends StringKeyOf<TypeRTLookupMap>>(
|
||||
key: K,
|
||||
provider: TypeRTLookupProvider<
|
||||
TypeRTLookupHost<TypeRTLookupMap[K]>,
|
||||
TypeRTLookupWire<TypeRTLookupMap[K]>
|
||||
>,
|
||||
): TypeRTDisposer
|
||||
/**
|
||||
* Look up one provider by runtime key.
|
||||
* @param key - descriptor lookup key.
|
||||
* @returns the live provider, or `undefined` when absent.
|
||||
*/
|
||||
get(key: string): TypeRTLookupProvider | undefined
|
||||
/** @returns a snapshot of registered provider keys. */
|
||||
keys(): readonly string[]
|
||||
/**
|
||||
* Observe later lookup changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Runtime registry for Host Context resolvers and Client Context binders. */
|
||||
export interface TypeRTContextRegistry {
|
||||
/**
|
||||
* Register a Host Context resolver.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param provider - owning package's Host resolver.
|
||||
* @returns disposer withdrawing the exact provider.
|
||||
*/
|
||||
registerHost<K extends StringKeyOf<TypeRTContextMap>>(
|
||||
key: K,
|
||||
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
): TypeRTDisposer
|
||||
/**
|
||||
* Register a Client Context identity binder.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param binder - Client scope identity resolver.
|
||||
* @returns disposer withdrawing the exact binder.
|
||||
*/
|
||||
registerClient<K extends StringKeyOf<TypeRTContextMap>>(
|
||||
key: K,
|
||||
binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
): TypeRTDisposer
|
||||
/**
|
||||
* Look up a Host Context resolver.
|
||||
* @param key - descriptor Context key.
|
||||
* @returns the provider, or `undefined` when absent.
|
||||
*/
|
||||
getHost(key: string): TypeRTHostContextProvider | undefined
|
||||
/**
|
||||
* Look up a Client Context binder.
|
||||
* @param key - descriptor Context key.
|
||||
* @returns the binder, or `undefined` when absent.
|
||||
*/
|
||||
getClient(key: string): TypeRTClientContextBinder | undefined
|
||||
/**
|
||||
* Observe later Context provider changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Minimal TypeRT runtime consumed through dependency inversion. */
|
||||
export interface TypeRTService {
|
||||
readonly local: TypeRTLocalRegistry
|
||||
readonly remotes: TypeRTRemoteRegistry
|
||||
readonly lookups: TypeRTLookupRegistry
|
||||
readonly contexts: TypeRTContextRegistry
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
typert: TypeRTService
|
||||
}
|
||||
}
|
||||
29
packages/typert/type-meta/tests/fixtures/source-launch.ts
vendored
Normal file
29
packages/typert/type-meta/tests/fixtures/source-launch.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
remoteMethods,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
class Goals {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
@Remote
|
||||
create(value: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
@RemoteContext('agent')
|
||||
scoped(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const methods = remoteMethods(new Goals())
|
||||
const actual = JSON.stringify(methods)
|
||||
const expected = JSON.stringify([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'agent' } },
|
||||
])
|
||||
if (actual !== expected) throw new Error(`unexpected Remote declarations: ${actual}`)
|
||||
process.stdout.write(actual)
|
||||
132
packages/typert/type-meta/tests/type-meta.spec.ts
Normal file
132
packages/typert/type-meta/tests/type-meta.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
remoteMethods,
|
||||
type TypeRTContext,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
metaFixture: TypeRTContext<string>
|
||||
}
|
||||
}
|
||||
|
||||
describe('type-meta Remote declarations', () => {
|
||||
it('executes standard decorator syntax through the Vitest source transform', () => {
|
||||
class Goals {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
@Remote
|
||||
create(value: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
@RemoteContext('metaFixture')
|
||||
scoped(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const goals = new Goals()
|
||||
expect(remoteMethods(goals)).toEqual([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('executes standard decorator syntax through the TSX source launcher', () => {
|
||||
const fixture = fileURLToPath(new URL('./fixtures/source-launch.ts', import.meta.url))
|
||||
const output = execFileSync(process.execPath, ['--import', 'tsx/esm', fixture], { encoding: 'utf8' })
|
||||
expect(JSON.parse(output)).toEqual([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'agent' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps decorator markers in private module state', () => {
|
||||
class Goals {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
create(agent: object, request: object): object {
|
||||
return { agent, request }
|
||||
}
|
||||
|
||||
scoped(request: object): object {
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
const initializers: Array<(this: Goals) => void> = []
|
||||
Remote(
|
||||
Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown,
|
||||
methodContext('create', initializers),
|
||||
)
|
||||
RemoteContext('metaFixture')(
|
||||
Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown,
|
||||
methodContext('scoped', initializers),
|
||||
)
|
||||
|
||||
const goals = new Goals()
|
||||
for (const initialize of initializers) initialize.call(goals)
|
||||
expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' })
|
||||
expect(Object.isFrozen(goals.typertGateway)).toBe(true)
|
||||
expect(remoteMethods(goals)).toEqual([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
|
||||
])
|
||||
expect(Reflect.ownKeys(Goals)).toEqual(['length', 'name', 'prototype'])
|
||||
expect(Reflect.ownKeys(Goals.prototype)).toEqual(['constructor', 'create', 'scoped'])
|
||||
})
|
||||
|
||||
it('keeps markers idempotent across instances and returns detached snapshots', () => {
|
||||
class Service {
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const initializers: Array<(this: Service) => void> = []
|
||||
Remote(
|
||||
Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown,
|
||||
methodContext('run', initializers),
|
||||
)
|
||||
|
||||
const first = new Service()
|
||||
const second = new Service()
|
||||
for (const initialize of initializers) {
|
||||
initialize.call(first)
|
||||
initialize.call(second)
|
||||
}
|
||||
const snapshot = remoteMethods(first)
|
||||
expect(remoteMethods(second)).toEqual(snapshot)
|
||||
;(snapshot as unknown as { method: string }[])[0]!.method = 'changed'
|
||||
expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }])
|
||||
})
|
||||
|
||||
it('rejects ambiguous binding names', () => {
|
||||
expect(() => bindTypeRTGateway({}, '')).toThrow('service key')
|
||||
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
|
||||
})
|
||||
})
|
||||
|
||||
function methodContext<This extends object>(
|
||||
name: string,
|
||||
initializers: Array<(this: This) => void>,
|
||||
): ClassMethodDecoratorContext<This, (this: This, ...args: unknown[]) => unknown> {
|
||||
return {
|
||||
kind: 'method',
|
||||
name,
|
||||
static: false,
|
||||
private: false,
|
||||
metadata: {},
|
||||
access: {
|
||||
has: object => name in object,
|
||||
get: object => (object as Record<string, unknown>)[name] as (this: This, ...args: unknown[]) => unknown,
|
||||
},
|
||||
addInitializer: (initializer) => { initializers.push(initializer) },
|
||||
}
|
||||
}
|
||||
21
packages/typert/type-meta/tsconfig.json
Normal file
21
packages/typert/type-meta/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user