fix(client): preserve effect schema types

This commit is contained in:
Dax 2026-07-04 21:31:08 +00:00
commit b4011d7b27
4 changed files with 646 additions and 329 deletions

View file

@ -7,11 +7,96 @@ import {
groupNames,
promiseOmitEndpoints,
} from "@opencode-ai/protocol/client"
import { Effect } from "effect"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Credential } from "@opencode-ai/schema/credential"
import { Event } from "@opencode-ai/schema/event"
import { EventLog } from "@opencode-ai/schema/event-log"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Form } from "@opencode-ai/schema/form"
import { Integration } from "@opencode-ai/schema/integration"
import { LLM } from "@opencode-ai/schema/llm"
import { Location } from "@opencode-ai/schema/location"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Model } from "@opencode-ai/schema/model"
import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Project } from "@opencode-ai/schema/project"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Provider } from "@opencode-ai/schema/provider"
import { Pty } from "@opencode-ai/schema/pty"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { Question } from "@opencode-ai/schema/question"
import { Reference } from "@opencode-ai/schema/reference"
import { Revert } from "@opencode-ai/schema/revert"
import { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Effect, Schema } from "effect"
import { fileURLToPath } from "url"
const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints })
const effectTypeReferences = [
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
...namespaceTypes("EventManifest", "@opencode-ai/schema/event-manifest", EventManifest),
...namespaceTypes("FileDiff", "@opencode-ai/schema/file-diff", FileDiff),
...namespaceTypes("FileSystem", "@opencode-ai/schema/filesystem", FileSystem),
...namespaceTypes("Form", "@opencode-ai/schema/form", Form),
...namespaceTypes("Integration", "@opencode-ai/schema/integration", Integration),
...namespaceTypes("LLM", "@opencode-ai/schema/llm", LLM),
...namespaceTypes("Location", "@opencode-ai/schema/location", Location),
...namespaceTypes("Mcp", "@opencode-ai/schema/mcp", Mcp),
...namespaceTypes("Model", "@opencode-ai/schema/model", Model),
...namespaceTypes("Permission", "@opencode-ai/schema/permission", Permission),
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
...namespaceTypes("PtyTicket", "@opencode-ai/schema/pty-ticket", PtyTicket),
...namespaceTypes("Question", "@opencode-ai/schema/question", Question),
...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
...namespaceTypes("Revert", "@opencode-ai/schema/revert", Revert),
...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
...namespaceTypes("SessionContextEntry", "@opencode-ai/schema/session-context-entry", SessionContextEntry),
...namespaceTypes("SessionInput", "@opencode-ai/schema/session-input", SessionInput),
...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
...namespaceTypes("SessionEvent", "@opencode-ai/schema/session-event", SessionEvent, {
Durable: "DurableEvent",
All: "Event",
}),
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
...namespaceTypes("Workspace", "@opencode-ai/schema/workspace", Workspace),
typeReference("Prompt", "@opencode-ai/schema/prompt", Prompt),
typeReference("Source", "@opencode-ai/schema/prompt", Source),
typeReference("FileAttachment", "@opencode-ai/schema/prompt", FileAttachment),
typeReference("AgentAttachment", "@opencode-ai/schema/prompt", AgentAttachment),
typeReference("AbsolutePath", "@opencode-ai/schema/schema", AbsolutePath),
typeReference("RelativePath", "@opencode-ai/schema/schema", RelativePath),
typeReference("SessionsCursor", "@opencode-ai/protocol/groups/session", SessionsCursor),
]
await Effect.runPromise(
Effect.all(
@ -32,10 +117,28 @@ await Effect.runPromise(
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
),
write(
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
emitEffectShape(effectContract, {
module: "../../contract",
api: "ClientApi",
typeReferences: effectTypeReferences,
}),
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
),
],
{ concurrency: 3, discard: true },
).pipe(Effect.provide(NodeFileSystem.layer)),
)
function namespaceTypes(namespace: string, module: string, values: object, names?: Readonly<Record<string, string>>) {
return Object.entries(values).flatMap(([name, schema]) =>
Schema.isSchema(schema) ? [typeReference(`${namespace}.${names?.[name] ?? name}`, module, schema)] : [],
)
}
function typeReference(name: string, module: string, schema: Schema.Top) {
return {
schema,
name,
import: `import type { ${name.split(".")[0]} } from ${JSON.stringify(module)}`,
}
}

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,14 @@ export type Contract = {
readonly groups: ReadonlyArray<Group>
}
export type EffectTypeReference = {
readonly schema: Schema.Top
readonly name: string
readonly import: string
}
type ResolvedEffectTypeReference = Omit<EffectTypeReference, "schema"> & { readonly ast: SchemaAST.AST }
export class GenerationError extends Schema.TaggedErrorClass<GenerationError>()("GenerationError", {
reason: Schema.String,
}) {
@ -268,7 +276,11 @@ export function emitEffectImported(
export function emitEffectShape(
contract: Contract,
options: { readonly module: string; readonly api: string },
options: {
readonly module: string
readonly api: string
readonly typeReferences?: ReadonlyArray<EffectTypeReference>
},
): Output {
return {
operations: operations(contract.groups),
@ -307,7 +319,16 @@ export function emitPromise(
}
}
function renderEffectShape(groups: ReadonlyArray<Group>, options: { readonly module: string; readonly api: string }) {
function renderEffectShape(
groups: ReadonlyArray<Group>,
options: {
readonly module: string
readonly api: string
readonly typeReferences?: ReadonlyArray<EffectTypeReference>
},
) {
const references = effectTypeReferences(options.typeReferences ?? [])
const imports = new Set<string>()
const endpointTypes = groups.map((group, groupIndex) => {
const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]`
const endpoints = group.endpoints.map((endpoint, endpointIndex) => {
@ -317,21 +338,29 @@ function renderEffectShape(groups: ReadonlyArray<Group>, options: { readonly mod
? ""
: `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(endpoint.endpoint.name)}]>[0]`
const input = endpoint.input
.map(
(field) =>
`readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`,
)
.map((field) => {
const schema = effectInputSchema(endpoint, field)
const type = schema === undefined ? undefined : effectType(schema, references, imports)?.source
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${type ?? `${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`}`
})
.join("; ")
const inputType = endpoint.operation.inputMode === "none" ? "" : `export type ${prefix}Input = { ${input} }`
const rawOutput = `EffectValue<ReturnType<${rawGroup}[${JSON.stringify(endpoint.endpoint.name)}]>>`
const outputType = isStreamSchema(endpoint.successes[0])
? `export type ${prefix}Output = StreamValue<${rawOutput}>`
: `export type ${prefix}Output = ${endpoint.unwrapData ? `(${rawOutput})["data"]` : rawOutput}`
const schema = effectOutputSchema(endpoint)
const rendered = schema === undefined ? undefined : effectType(schema, references, imports)
const type = rendered?.source
const fallback = isStreamSchema(endpoint.successes[0])
? `StreamValue<${rawOutput}>`
: endpoint.unwrapData
? `(${rawOutput})["data"]`
: rawOutput
const outputType = `export type ${prefix}Output = ${type ?? fallback}`
const operationOutput = rendered?.authoritative ? rendered.source : `${prefix}Output`
return [
request,
endpoint.operation.inputMode === "none" ? "" : inputType,
outputType,
`export type ${groupShapeTypeName(group, endpoint)}<E = never> = (${endpoint.operation.inputMode === "none" ? "" : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input`}) => ${endpoint.operation.success === "stream" ? `Stream.Stream<${prefix}Output, E>` : `Effect.Effect<${prefix}Output, E>`}`,
`export type ${groupShapeTypeName(group, endpoint)}<E = never> = (${endpoint.operation.inputMode === "none" ? "" : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input`}) => ${endpoint.operation.success === "stream" ? `Stream.Stream<${operationOutput}, E>` : `Effect.Effect<${operationOutput}, E>`}`,
]
.filter(Boolean)
.join("\n")
@ -355,6 +384,7 @@ function renderEffectShape(groups: ReadonlyArray<Group>, options: { readonly mod
import type { Effect, Stream } from "effect"
import type { HttpApiClient } from "effect/unstable/httpapi"
import type { ${options.api} } from ${JSON.stringify(options.module)}
${[...imports].join("\n")}
type RawClient = HttpApiClient.ForApi<typeof ${options.api}>
type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never
@ -368,6 +398,102 @@ ${clientFields.join("\n")}
`
}
function effectTypeReferences(input: ReadonlyArray<EffectTypeReference>) {
const names = new Map<string, ResolvedEffectTypeReference>()
const asts = new Map<SchemaAST.AST, ResolvedEffectTypeReference>()
const brands = new Map<string, ResolvedEffectTypeReference>()
for (const reference of input) {
const value = { name: reference.name, import: reference.import, ast: reference.schema.ast }
asts.set(reference.schema.ast, value)
asts.set(Schema.toType(reference.schema).ast, value)
const document = SchemaRepresentation.toCodeDocument(
SchemaRepresentation.fromASTs([Schema.toType(reference.schema).ast]),
)
const name = document.codes[0]?.Type
const type =
name === undefined
? undefined
: (document.references.nonRecursives.find((item) => item.$ref === name)?.code.Type ?? name)
if (type?.includes("Brand.Brand<") && !brands.has(type)) brands.set(type, value)
if (name === undefined || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) continue
const previous = names.get(name)
if (previous !== undefined) {
if (previous.ast !== reference.schema.ast) {
throw new GenerationError({ reason: `Conflicting Effect type reference: ${name}` })
}
continue
}
names.set(name, value)
}
return { names, asts, brands }
}
function effectType(schema: Schema.Top, references: ReturnType<typeof effectTypeReferences>, imports: Set<string>) {
const projected = Schema.toType(schema)
const direct = references.asts.get(schema.ast) ?? references.asts.get(projected.ast)
if (direct !== undefined) {
imports.add(direct.import)
return { source: direct.name, authoritative: true }
}
const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([projected.ast]))
const source = new Map(document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]))
const expand = (type: string, seen = new Set<string>()): string => {
for (const [name, value] of source) {
const pattern = typeReferencePattern(name)
if (!pattern.test(type)) continue
const reference = references.names.get(name)
if (reference !== undefined) {
imports.add(reference.import)
type = type.replace(typeReferencePattern(name), reference.name)
continue
}
if (seen.has(name)) return type
type = type.replace(typeReferencePattern(name), `(${expand(value, new Set([...seen, name]))})`)
}
return type
}
const root = document.codes[0].Type
const authoritative = references.names.has(root)
let type = expand(root)
for (const [brand, reference] of references.brands) {
if (!type.includes(brand)) continue
imports.add(reference.import)
type = type.replaceAll(brand, reference.name)
}
if (/\b(?:Brand|Schema)\./.test(type)) return undefined
return { source: type, authoritative }
}
function effectInputSchema(endpoint: Endpoint, field: InputField) {
const schema =
field.source === "params"
? endpoint.params
: field.source === "query"
? endpoint.query
: field.source === "headers"
? endpoint.headers
: endpoint.payloads[0]
if (schema === undefined) return undefined
const ast = Schema.toType(schema).ast
if (!SchemaAST.isObjects(ast)) return undefined
const property = ast.propertySignatures.find((property) => property.name === field.name)
return property === undefined ? undefined : Schema.make(property.type)
}
function effectOutputSchema(endpoint: Endpoint) {
const schema = endpoint.successes[0]
if (HttpApiSchema.isNoContent(schema.ast)) return undefined
if (isStreamSchema(schema)) {
if (schema._tag === "StreamUint8Array") return undefined
return schema.sseMode === "data" ? streamDataSchema(schema) : schema.events
}
if (!endpoint.unwrapData) return schema
const ast = Schema.toType(schema).ast
if (!SchemaAST.isObjects(ast)) return undefined
const data = ast.propertySignatures.find((property) => property.name === "data")
return data === undefined ? undefined : Schema.make(data.type)
}
function groupShapeName(group: Group) {
return `${identifierPart(group.identifier)}Api`
}

View file

@ -9,6 +9,7 @@ import {
compile as compileContract,
emitEffect,
emitEffectImported,
emitEffectShape,
emitPromise,
generate,
GenerationError,
@ -88,6 +89,43 @@ describe("HttpApiCodegen.generate", () => {
)
})
test("emits authoritative schema types in the Effect API shape", () => {
const ID = Schema.String.pipe(Schema.brand("SessionID"))
const Info = Schema.Struct({ id: Schema.String }).annotate({ identifier: "Session.Info" })
const output = emitEffectShape(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:id", {
params: { id: ID },
success: Schema.Struct({ data: Info }),
}),
),
),
{
module: "@example/api",
api: "Api",
typeReferences: [
{
schema: ID,
name: "Session.ID",
import: 'import type { Session } from "@example/schema"',
},
{
schema: Info,
name: "Session.Info",
import: 'import type { Session } from "@example/schema"',
},
],
},
)
const source = output.files.find((file) => file.path === "api.ts")?.content
expect(source).toContain('import type { Session } from "@example/schema"')
expect(source).toContain('export type Endpoint0_0Input = { readonly "id": Session.ID }')
expect(source).toContain("export type Endpoint0_0Output = Session.Info")
expect(source).toContain("Effect.Effect<Session.Info, E>")
})
test("projects imported endpoint constants into a generated API", () => {
const output = emitEffectImported(
compileContract(