feat: add TypeRT remote gateway infrastructure
This commit is contained in:
@@ -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 {}' }
|
||||
|
||||
Reference in New Issue
Block a user