mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 07:26:47 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4011d7b27 | |||
| 3f034462c4 |
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}) {
|
||||
@@ -57,6 +65,14 @@ export type Endpoint = {
|
||||
readonly unwrapData: boolean
|
||||
readonly errors: ReadonlyArray<{ readonly status: number; readonly schema: Schema.Top }>
|
||||
readonly successes: ReadonlyArray<Schema.Top>
|
||||
readonly wire: {
|
||||
readonly params: Schema.Top | undefined
|
||||
readonly query: Schema.Top | undefined
|
||||
readonly headers: Schema.Top | undefined
|
||||
readonly payloads: ReadonlyArray<Schema.Top>
|
||||
readonly errors: ReadonlyArray<{ readonly status: number; readonly schema: Schema.Top }>
|
||||
readonly successes: ReadonlyArray<Schema.Top>
|
||||
}
|
||||
readonly effectPortable: boolean
|
||||
}
|
||||
|
||||
@@ -76,6 +92,12 @@ type PromiseInputField =
|
||||
| (InputField & { readonly optional: boolean })
|
||||
| { readonly name: string; readonly source: "wildcard"; readonly optional: false }
|
||||
|
||||
type PromiseReferences = {
|
||||
readonly types: Map<string, string>
|
||||
readonly variants: Map<string, Map<string, string>>
|
||||
readonly names: Set<string>
|
||||
}
|
||||
|
||||
const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
|
||||
const resolveHttpApiEncoding = SchemaAST.resolveAt<HttpApiSchema.Encoding>("~httpApiEncoding")
|
||||
const resolveContentSchema = SchemaAST.resolveAt<SchemaAST.AST>("contentSchema")
|
||||
@@ -161,6 +183,14 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
|
||||
unwrapData: isDataEnvelope(success.schema),
|
||||
successes: [success.schema],
|
||||
errors: errorSchemas.map((item) => ({ status: item.status, schema: item.schema })),
|
||||
wire: {
|
||||
params: params?.wire,
|
||||
query: query?.wire,
|
||||
headers: headers?.wire,
|
||||
payloads: payloads.map((item) => item.wire),
|
||||
successes: [success.wire],
|
||||
errors: errorSchemas.map((item) => ({ status: item.status, schema: item.wire })),
|
||||
},
|
||||
effectPortable,
|
||||
operation: {
|
||||
group: groupName,
|
||||
@@ -246,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),
|
||||
@@ -285,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) => {
|
||||
@@ -295,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")
|
||||
@@ -333,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
|
||||
@@ -346,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`
|
||||
}
|
||||
@@ -356,7 +504,7 @@ function groupShapeTypeName(group: Group, endpoint: Endpoint) {
|
||||
|
||||
function assertPromiseEndpoint(endpoint: Endpoint) {
|
||||
const name = `${endpoint.group}.${endpoint.endpoint.name}`
|
||||
const payload = endpoint.payloads[0]
|
||||
const payload = endpoint.wire.payloads[0]
|
||||
const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast)
|
||||
if (
|
||||
payload !== undefined &&
|
||||
@@ -364,7 +512,7 @@ function assertPromiseEndpoint(endpoint: Endpoint) {
|
||||
) {
|
||||
throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` })
|
||||
}
|
||||
const success = endpoint.successes[0]
|
||||
const success = endpoint.wire.successes[0]
|
||||
if (isStreamSchema(success)) {
|
||||
if (
|
||||
success._tag !== "StreamSse" ||
|
||||
@@ -379,7 +527,7 @@ function assertPromiseEndpoint(endpoint: Endpoint) {
|
||||
throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
|
||||
}
|
||||
}
|
||||
for (const error of endpoint.errors) {
|
||||
for (const error of endpoint.wire.errors) {
|
||||
if (declaredErrorFields(error.schema) === undefined) {
|
||||
throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` })
|
||||
}
|
||||
@@ -521,24 +669,35 @@ function renderPromiseTypes(
|
||||
outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>,
|
||||
) {
|
||||
const types = new Map<SchemaAST.AST, string>()
|
||||
const references: PromiseReferences = { types: new Map(), variants: new Map(), names: new Set() }
|
||||
const typeOf = (schema: Schema.Top, decoded = false) => {
|
||||
const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
|
||||
const cached = types.get(projected.ast)
|
||||
if (cached !== undefined) return cached
|
||||
const type = structuralType(projected)
|
||||
const type = structuralType(projected, references)
|
||||
types.set(projected.ast, type)
|
||||
return type
|
||||
}
|
||||
const errors = new Map(
|
||||
groups.flatMap((group) =>
|
||||
group.endpoints.flatMap((endpoint) =>
|
||||
endpoint.errors.flatMap((error) => {
|
||||
endpoint.wire.errors.flatMap((error) => {
|
||||
const tagged = declaredErrorFields(error.schema)
|
||||
return tagged === undefined ? [] : [[tagged.tag, tagged] as const]
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
references.names.add("JsonValue")
|
||||
for (const error of errors.values()) references.names.add(error.identifier)
|
||||
for (const override of Object.values(outputTypes ?? {})) references.names.add(override.name)
|
||||
for (const group of groups) {
|
||||
for (const endpoint of group.endpoints) {
|
||||
const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
|
||||
references.names.add(`${prefix}Input`)
|
||||
references.names.add(`${prefix}Output`)
|
||||
}
|
||||
}
|
||||
const errorTypes = Array.from(errors.values()).map((error) => {
|
||||
const fields = error.fields
|
||||
.map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`)
|
||||
@@ -550,10 +709,10 @@ function renderPromiseTypes(
|
||||
group.endpoints.flatMap((endpoint) => {
|
||||
const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
|
||||
const schemas = {
|
||||
params: endpoint.params,
|
||||
query: endpoint.query,
|
||||
headers: endpoint.headers,
|
||||
payload: endpoint.payloads[0],
|
||||
params: endpoint.wire.params,
|
||||
query: endpoint.wire.query,
|
||||
headers: endpoint.wire.headers,
|
||||
payload: endpoint.wire.payloads[0],
|
||||
}
|
||||
const input = promiseInput(endpoint)
|
||||
.map((field) => {
|
||||
@@ -564,7 +723,7 @@ function renderPromiseTypes(
|
||||
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]`
|
||||
})
|
||||
.join("; ")
|
||||
const successSchema = endpoint.successes[0]
|
||||
const successSchema = endpoint.wire.successes[0]
|
||||
const success =
|
||||
outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ??
|
||||
typeOf(
|
||||
@@ -581,11 +740,12 @@ function renderPromiseTypes(
|
||||
}),
|
||||
)
|
||||
.join("\n\n")
|
||||
const json = operations.includes("JsonValue")
|
||||
const referenceTypes = Array.from(references.types, ([name, type]) => `export type ${name} = ${type}`).join("\n\n")
|
||||
const json = [referenceTypes, operations, ...errorTypes].some((source) => source.includes("JsonValue"))
|
||||
? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"
|
||||
: ""
|
||||
const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
|
||||
return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n")
|
||||
return [...imports, json, ...errorTypes, referenceTypes, operations].filter(Boolean).join("\n\n")
|
||||
}
|
||||
|
||||
function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||
@@ -612,14 +772,14 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||
: `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }`
|
||||
}
|
||||
const parts = [
|
||||
endpoint.query === undefined ? undefined : `query: ${part("query")}`,
|
||||
endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`,
|
||||
endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
|
||||
endpoint.wire.query === undefined ? undefined : `query: ${part("query")}`,
|
||||
endpoint.wire.headers === undefined ? undefined : `headers: ${part("headers")}`,
|
||||
endpoint.wire.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
|
||||
].filter((value): value is string => value !== undefined)
|
||||
const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
|
||||
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }`
|
||||
const declaredStatuses = [...new Set(endpoint.wire.errors.map((error) => error.status))]
|
||||
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.wire.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.wire.successes[0]) ? ", binary: true" : ""} }`
|
||||
if (endpoint.operation.success === "stream") {
|
||||
const success = endpoint.successes[0]
|
||||
const success = endpoint.wire.successes[0]
|
||||
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
|
||||
throw new GenerationError({
|
||||
reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`,
|
||||
@@ -652,7 +812,7 @@ function identifierPart(value: string) {
|
||||
.join("")
|
||||
}
|
||||
|
||||
function structuralType(schema: Schema.Top) {
|
||||
function structuralType(schema: Schema.Top, references: PromiseReferences) {
|
||||
const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast]))
|
||||
if (
|
||||
document.artifacts.some(
|
||||
@@ -663,28 +823,62 @@ function structuralType(schema: Schema.Top) {
|
||||
) {
|
||||
throw new GenerationError({ reason: "Referenced Promise types are not implemented" })
|
||||
}
|
||||
const references = new Map(
|
||||
document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]),
|
||||
const source = new Map(
|
||||
document.references.nonRecursives.map((reference) => [reference.$ref, promiseType(reference.code.Type)]),
|
||||
)
|
||||
const expand = (type: string, seen = new Set<string>()): string => {
|
||||
for (const [reference, value] of references) {
|
||||
const pattern = `(?<![A-Za-z0-9_$.'"])${reference.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`
|
||||
if (!new RegExp(pattern).test(type)) continue
|
||||
if (seen.has(reference)) {
|
||||
throw new GenerationError({ reason: `Recursive Promise types are not implemented: ${reference}` })
|
||||
}
|
||||
type = type.replace(new RegExp(pattern, "g"), `(${expand(value, new Set([...seen, reference]))})`)
|
||||
for (const [name, value] of source) {
|
||||
const pattern = typeReferencePattern(name)
|
||||
if (!pattern.test(type)) continue
|
||||
if (seen.has(name)) throw new GenerationError({ reason: `Recursive Promise type reference: ${name}` })
|
||||
type = type.replace(pattern, `(${expand(value, new Set([...seen, name]))})`)
|
||||
}
|
||||
return type
|
||||
}
|
||||
return expand(document.codes[0].Type)
|
||||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue")
|
||||
const names = new Map<string, string>()
|
||||
for (const reference of document.references.nonRecursives) {
|
||||
const canonical = expand(source.get(reference.$ref)!)
|
||||
const variants = references.variants.get(reference.$ref) ?? new Map<string, string>()
|
||||
const existing = variants.get(canonical)
|
||||
if (existing !== undefined) {
|
||||
names.set(reference.$ref, existing)
|
||||
continue
|
||||
}
|
||||
const name = uniqueTypeName(identifierPart(reference.$ref), references.names)
|
||||
references.names.add(name)
|
||||
variants.set(canonical, name)
|
||||
references.variants.set(reference.$ref, variants)
|
||||
names.set(reference.$ref, name)
|
||||
}
|
||||
const rewrite = (type: string) => {
|
||||
for (const [from, to] of names) type = type.replace(typeReferencePattern(from), to)
|
||||
return type
|
||||
}
|
||||
for (const reference of document.references.nonRecursives) {
|
||||
const name = names.get(reference.$ref)!
|
||||
if (!references.types.has(name)) references.types.set(name, rewrite(source.get(reference.$ref)!))
|
||||
}
|
||||
return rewrite(promiseType(document.codes[0].Type))
|
||||
}
|
||||
|
||||
function typeReferencePattern(name: string) {
|
||||
return new RegExp(`(?<![A-Za-z0-9_$.'"])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`, "g")
|
||||
}
|
||||
|
||||
function uniqueTypeName(name: string, names: ReadonlySet<string>) {
|
||||
if (!names.has(name)) return name
|
||||
let suffix = 2
|
||||
while (names.has(`${name}${suffix}`)) suffix++
|
||||
return `${name}${suffix}`
|
||||
}
|
||||
|
||||
function promiseType(type: string) {
|
||||
return type.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "").replaceAll("Schema.Json", "JsonValue")
|
||||
}
|
||||
|
||||
function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Group>) {
|
||||
const endpoints = groups.flatMap((group) => group.endpoints)
|
||||
const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.successes[0]))
|
||||
const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.wire.successes[0]))
|
||||
const usesWildcard = endpoints.some((endpoint) => promiseWildcardInput(endpoint) !== undefined)
|
||||
|
||||
const sseReady = replaceOne(content, "let next: ReadableStreamReadResult<Uint8Array>", "let next")
|
||||
@@ -769,7 +963,7 @@ function normalizeTransport(
|
||||
operation: string,
|
||||
) {
|
||||
if (schema === undefined) return undefined
|
||||
if (isStreamSchema(schema)) return { schema, effectPortable: true } as const
|
||||
if (isStreamSchema(schema)) return { schema, wire: schema, effectPortable: true } as const
|
||||
if (!metadataPortable(schema.ast, new Set())) {
|
||||
throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` })
|
||||
}
|
||||
@@ -798,8 +992,8 @@ function normalizeTransport(
|
||||
? Array.from(rebuilt.success)[0]
|
||||
: Array.from(rebuilt.error)[0]
|
||||
if (normalized === undefined) throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` })
|
||||
if (!sameEncoding(schema.ast, normalized.ast)) return { schema, effectPortable: false } as const
|
||||
return { schema: decoded, effectPortable: true } as const
|
||||
if (!sameEncoding(schema.ast, normalized.ast)) return { schema, wire: schema, effectPortable: false } as const
|
||||
return { schema: decoded, wire: schema, effectPortable: true } as const
|
||||
}
|
||||
|
||||
function isPathInput(path: string): path is HttpRouter.PathInput {
|
||||
|
||||
@@ -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(
|
||||
@@ -270,8 +308,8 @@ describe("HttpApiCodegen.generate", () => {
|
||||
expect(types).not.toContain("Brand")
|
||||
})
|
||||
|
||||
test("inlines non-recursive references in Promise wire types", () => {
|
||||
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
|
||||
test("preserves non-recursive references as named Promise wire types", () => {
|
||||
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Session.Info" })
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
@@ -282,12 +320,12 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
||||
'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
expect(types).toContain('export type SessionInfo = { readonly "value": string }')
|
||||
expect(types).toContain('export type SessionGetOutput = ({ readonly "data": SessionInfo })["data"]')
|
||||
})
|
||||
|
||||
test("expands Promise references only at identifier boundaries", () => {
|
||||
test("preserves distinct Promise reference names at identifier boundaries", () => {
|
||||
const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
|
||||
identifier: "Session",
|
||||
})
|
||||
@@ -302,9 +340,47 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
||||
'readonly "session": ({ readonly "name": "Session", readonly "id": string })',
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
expect(types).toContain('export type Session = { readonly "name": "Session", readonly "id": string }')
|
||||
expect(types).toContain("export type SessionID = string")
|
||||
expect(types).toContain('readonly "session": Session, readonly "sessionID": SessionID')
|
||||
})
|
||||
|
||||
test("preserves encoded shapes in named Promise wire types", () => {
|
||||
const Referenced = Schema.Struct({ value: Schema.Number }).annotate({ identifier: "Referenced" })
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: Schema.Struct({ data: Referenced }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
expect(types).toContain('export type Referenced = { readonly "value": number | "Infinity" | "-Infinity" | "NaN" }')
|
||||
expect(types).toContain('export type SessionGetOutput = ({ readonly "data": Referenced })["data"]')
|
||||
})
|
||||
|
||||
test("distinguishes decoded inputs from encoded Promise references", () => {
|
||||
const Referenced = Schema.Struct({ value: Schema.NumberFromString }).annotate({ identifier: "Referenced" })
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
query: { filter: Referenced },
|
||||
success: Schema.Struct({ data: Referenced }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
expect(types).toContain('export type Referenced = { readonly "value": number }')
|
||||
expect(types).toContain('export type Referenced2 = { readonly "value": string }')
|
||||
expect(types).toContain('readonly "filter": ({ readonly "filter": Referenced })["filter"]')
|
||||
expect(types).toContain('export type SessionGetOutput = ({ readonly "data": Referenced2 })["data"]')
|
||||
})
|
||||
|
||||
test("emits Effect Json schemas as standalone Promise types", () => {
|
||||
|
||||
Reference in New Issue
Block a user