From 3085279724c1a7d620b633ded1302d03f06de874 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 21 Apr 2026 19:06:00 -0400 Subject: [PATCH 1/4] refactor(core): allow SyncEvent.define and BusEvent.define to accept Effect Schema Overloads BusEvent.define and SyncEvent.define so payload schemas can be passed as Effect Schema values directly. Effect Schemas are converted to Zod via the effect-zod walker since the sync/bus pipelines still use Zod internally. Migrates MessageV2.Event.* to use Schema.Struct directly instead of z.object with .zod references. --- packages/opencode/src/bus/bus-event.ts | 31 +++++++++++++--- packages/opencode/src/session/message-v2.ts | 40 ++++++++++---------- packages/opencode/src/sync/index.ts | 41 ++++++++++++++++++--- 3 files changed, 80 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index efaed944066c..bb9b3f497f67 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,19 +1,38 @@ import z from "zod" import type { ZodType } from "zod" +import { Schema, Types } from "effect" +import { zod } from "@/util/effect-zod" export type Definition = ReturnType const registry = new Map() -export function define(type: Type, properties: Properties) { - const result = { - type, - properties, - } - registry.set(type, result) +/** + * Define a bus event type with a payload schema. + * + * Accepts either a Zod schema or an Effect Schema. Effect Schemas are + * converted to Zod internally via the effect-zod walker so that the bus + * continues to use Zod as the lingua franca for serialization/validation. + */ +export function define( + type: Type, + properties: P, +): { type: Type; properties: z.ZodType>> } +export function define( + type: Type, + properties: P, +): { type: Type; properties: P } +export function define(type: string, properties: unknown) { + const zodProperties = isEffectSchema(properties) ? zod(properties) : (properties as ZodType) + const result = { type, properties: zodProperties } + registry.set(type, result as Definition) return result } +function isEffectSchema(value: unknown): value is Schema.Top { + return typeof value === "object" && value !== null && "ast" in value +} + export function payloads() { return registry .entries() diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 980dd4da844f..7664b09adaa5 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -581,48 +581,48 @@ export const Event = { type: "message.updated", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: Info.zod, + schema: Schema.Struct({ + sessionID: SessionID, + info: _Info, }), }), Removed: SyncEvent.define({ type: "message.removed", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, + schema: Schema.Struct({ + sessionID: SessionID, + messageID: MessageID, }), }), PartUpdated: SyncEvent.define({ type: "message.part.updated", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - part: Part.zod, - time: z.number(), + schema: Schema.Struct({ + sessionID: SessionID, + part: _Part, + time: Schema.Number, }), }), PartDelta: BusEvent.define( "message.part.delta", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - field: z.string(), - delta: z.string(), + Schema.Struct({ + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + field: Schema.String, + delta: Schema.String, }), ), PartRemoved: SyncEvent.define({ type: "message.part.removed", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, + schema: Schema.Struct({ + sessionID: SessionID, + messageID: MessageID, + partID: PartID, }), }), } diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 125d8c95506e..6fdad1662119 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,5 +1,6 @@ import z from "zod" import type { ZodObject } from "zod" +import { Schema, Types } from "effect" import { Database, eq } from "@/storage" import { GlobalBus } from "@/bus/global" import { Bus as ProjectBus } from "@/bus" @@ -9,6 +10,7 @@ import { EventSequenceTable, EventTable } from "./event.sql" import { WorkspaceContext } from "@/control-plane/workspace-context" import { EventID } from "./schema" import { Flag } from "@/flag/flag" +import { zod } from "@/util/effect-zod" export type Definition = { type: string @@ -69,31 +71,58 @@ export function versionedType(type: string, version?: number) { return version ? `${type}.${version}` : type } +type SchemaLike = + | ZodObject>> + | Schema.Struct> + +type BusSchemaLike = ZodObject | Schema.Struct + +type Mutable = Types.DeepMutable +type ToZodObject = S extends Schema.Top + ? z.ZodObject<{ [K in keyof Mutable>]: z.ZodType>[K]> }> + : S + +/** + * Define a sync event. Accepts either a Zod schema or an Effect Schema for + * both `schema` and `busSchema`. Effect Schemas are converted to Zod via the + * `effect-zod` walker since the sync pipeline uses Zod for validation and + * JSON Schema generation. + */ export function define< Type extends string, Agg extends string, - Schema extends ZodObject>>, - BusSchema extends ZodObject = Schema, ->(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) { + S extends SchemaLike, + B extends BusSchemaLike = S, +>(input: { type: Type; version: number; aggregate: Agg; schema: S; busSchema?: B }) { if (frozen) { throw new Error("Error defining sync event: sync system has been frozen") } + const schema = toZodObject(input.schema) as ToZodObject + const properties = (input.busSchema ? toZodObject(input.busSchema) : schema) as ToZodObject + const def = { type: input.type, version: input.version, aggregate: input.aggregate, - schema: input.schema, - properties: input.busSchema ? input.busSchema : input.schema, + schema, + properties, } versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) - registry.set(versionedType(def.type, def.version), def) + registry.set(versionedType(def.type, def.version), def as unknown as Definition) return def } +function toZodObject(value: ZodObject | Schema.Top): z.ZodObject { + if (typeof value === "object" && value !== null && "ast" in value) { + return zod(value as Schema.Top) as unknown as z.ZodObject + } + return value as z.ZodObject +} + export function project( def: Def, func: (db: Database.TxOrDb, data: Event["data"]) => void, From 96039bdb8396a701dc0a3664b0fea4b697408596 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Apr 2026 08:43:35 -0400 Subject: [PATCH 2/4] test(core): cover Effect Schema event definitions Exercise the new SyncEvent and BusEvent Schema overloads end to end and use Schema.isSchema for the conversion check so the mixed-schema path stays explicit and covered. --- packages/opencode/src/bus/bus-event.ts | 6 +-- packages/opencode/src/sync/index.ts | 2 +- packages/opencode/test/bus/bus.test.ts | 19 ++++++++++ packages/opencode/test/sync/index.test.ts | 46 +++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index bb9b3f497f67..90a0668dba52 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -23,16 +23,12 @@ export function define( properties: P, ): { type: Type; properties: P } export function define(type: string, properties: unknown) { - const zodProperties = isEffectSchema(properties) ? zod(properties) : (properties as ZodType) + const zodProperties = Schema.isSchema(properties) ? zod(properties) : (properties as ZodType) const result = { type, properties: zodProperties } registry.set(type, result as Definition) return result } -function isEffectSchema(value: unknown): value is Schema.Top { - return typeof value === "object" && value !== null && "ast" in value -} - export function payloads() { return registry .entries() diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 6fdad1662119..fdedff03668e 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -117,7 +117,7 @@ export function define< } function toZodObject(value: ZodObject | Schema.Top): z.ZodObject { - if (typeof value === "object" && value !== null && "ast" in value) { + if (Schema.isSchema(value)) { return zod(value as Schema.Top) as unknown as z.ZodObject } return value as z.ZodObject diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index 3df179787dd3..7cde38a01426 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" +import { Schema } from "effect" import z from "zod" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" @@ -10,6 +11,8 @@ const TestEvent = { Pong: BusEvent.define("test.pong", z.object({ message: z.string() })), } +const EffectTestEvent = BusEvent.define("test.effect-schema.ping", Schema.Struct({ value: Schema.Number })) + function withInstance(directory: string, fn: () => Promise) { return Instance.provide({ directory, fn }) } @@ -76,6 +79,22 @@ describe("Bus", () => { await Bus.publish(TestEvent.Ping, { value: 1 }) }) }) + + test("accepts Effect Schema event definitions", async () => { + await using tmp = await tmpdir() + const received: number[] = [] + + await withInstance(tmp.path, async () => { + Bus.subscribe(EffectTestEvent, (evt) => { + received.push(evt.properties.value) + }) + await Bun.sleep(10) + await Bus.publish(EffectTestEvent, { value: 42 }) + await Bun.sleep(10) + }) + + expect(received).toEqual([42]) + }) }) describe("unsubscribe", () => { diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 866bcaa31ad0..81c78132ece4 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test" +import { Schema } from "effect" import { tmpdir } from "../fixture/fixture" import z from "zod" import { Bus } from "../../src/bus" @@ -128,6 +129,51 @@ describe("SyncEvent", () => { }) }), ) + + test( + "accepts Effect Schema event definitions", + withInstance(async () => { + SyncEvent.reset() + try { + const Created = SyncEvent.define({ + type: "item.effect.created", + version: 1, + aggregate: "id", + schema: Schema.Struct({ id: Schema.String, name: Schema.String }), + }) + + SyncEvent.init({ + projectors: [SyncEvent.project(Created, () => {})], + }) + + const events: Array<{ + type: string + properties: { id: string; name: string } + }> = [] + const received = new Promise((resolve) => { + Bus.subscribeAll((event) => { + events.push(event) + resolve() + }) + }) + + SyncEvent.run(Created, { id: "evt_1", name: "schema" }) + + await received + expect(events).toEqual([ + { + type: "item.effect.created", + properties: { + id: "evt_1", + name: "schema", + }, + }, + ]) + } finally { + setup() + } + }), + ) }) describe("replay", () => { From 25c30e06d85ff9d953178680a8e3382ceffff633 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Apr 2026 10:23:51 -0400 Subject: [PATCH 3/4] refactor(core): make SyncEvent schema-first Store sync event definitions as Effect Schema and derive Zod only at the bus and OpenAPI edges. Bridge the remaining Zod-native session payloads through Schema annotations so the sync layer no longer needs mixed-schema definition helpers. --- packages/opencode/src/server/projectors.ts | 4 +- packages/opencode/src/session/session.ts | 52 ++++++++------ packages/opencode/src/sync/index.ts | 79 +++++++++++----------- packages/opencode/test/sync/index.test.ts | 6 +- 4 files changed, 75 insertions(+), 66 deletions(-) diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index cfecce526599..c32e08693e8f 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import sessionProjectors from "../session/projectors" import { SyncEvent } from "@/sync" import { Session } from "@/session" @@ -10,7 +10,7 @@ export function initProjectors() { projectors: sessionProjectors, convertEvent: (type, data) => { if (type === "session.updated") { - const id = (data as z.infer).sessionID + const id = (data as Schema.Schema.Type).sessionID const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) if (!row) return data diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index d2bdbccb7dbc..46513801bed2 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -28,7 +28,7 @@ import type { Provider } from "@/provider" import { Permission } from "@/permission" import { Global } from "@/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { zod, zodObject } from "@/util/effect-zod" +import { ZodOverride, zod, zodObject } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const log = Log.create({ service: "session" }) @@ -215,40 +215,50 @@ export const MessagesInput = Schema.Struct({ limit: Schema.optional(Schema.Number), }).pipe(withStatics((s) => ({ zod: zod(s) }))) +function schemaFromZod(value: T) { + return Schema.declare((input): input is z.output => value.safeParse(input).success).annotate({ + [ZodOverride]: value, + }) +} + +const SessionUpdateInfoSchema = schemaFromZod( + updateSchema(zodObject(Info)).extend({ + share: updateSchema(zodObject(Share)).optional(), + time: updateSchema(zodObject(Time)).optional(), + }), +) + export const Event = { Created: SyncEvent.define({ type: "session.created", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: Info.zod, - }), + schema: { + sessionID: SessionID, + info: Info, + }, }), Updated: SyncEvent.define({ type: "session.updated", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: updateSchema(zodObject(Info)).extend({ - share: updateSchema(zodObject(Share)).optional(), - time: updateSchema(zodObject(Time)).optional(), - }), - }), - busSchema: z.object({ - sessionID: SessionID.zod, - info: Info.zod, - }), + schema: { + sessionID: SessionID, + info: SessionUpdateInfoSchema, + }, + busSchema: { + sessionID: SessionID, + info: Info, + }, }), Deleted: SyncEvent.define({ type: "session.deleted", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: Info.zod, - }), + schema: { + sessionID: SessionID, + info: Info, + }, }), Diff: BusEvent.define( "session.diff", @@ -394,7 +404,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Session") {} -type Patch = z.infer["info"] +type Patch = Schema.Schema.Type["info"] const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index fdedff03668e..2248ed15d2ab 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,5 +1,4 @@ import z from "zod" -import type { ZodObject } from "zod" import { Schema, Types } from "effect" import { Database, eq } from "@/storage" import { GlobalBus } from "@/bus/global" @@ -12,22 +11,34 @@ import { EventID } from "./schema" import { Flag } from "@/flag/flag" import { zod } from "@/util/effect-zod" +type StructLike = Fields | Schema.Struct + export type Definition = { type: string version: number aggregate: string - schema: z.ZodObject + schema: Schema.Top + busSchema: Schema.Top + properties: z.ZodTypeAny +} + +type MutableType = Types.DeepMutable> - // This is temporary and only exists for compatibility with bus - // event definitions - properties: z.ZodObject +type DefinedEvent = Definition & { + type: Type + aggregate: Agg + schema: SchemaDef + busSchema: BusDef + properties: z.ZodType> } +type Data = MutableType + export type Event = { id: string seq: number aggregateID: string - data: z.infer + data: Data } export type SerializedEvent = Event & { type: string } @@ -56,7 +67,7 @@ export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; co for (let [type, version] of versions.entries()) { let def = registry.get(versionedType(type, version))! - BusEvent.define(def.type, def.properties || def.schema) + BusEvent.define(def.type, def.properties) } // Freeze the system so it clearly errors if events are defined @@ -71,58 +82,46 @@ export function versionedType(type: string, version?: number) { return version ? `${type}.${version}` : type } -type SchemaLike = - | ZodObject>> - | Schema.Struct> - -type BusSchemaLike = ZodObject | Schema.Struct - -type Mutable = Types.DeepMutable -type ToZodObject = S extends Schema.Top - ? z.ZodObject<{ [K in keyof Mutable>]: z.ZodType>[K]> }> - : S +function struct(value: StructLike) { + return (Schema.isSchema(value) ? value : Schema.Struct(value as Fields)) as Schema.Struct +} -/** - * Define a sync event. Accepts either a Zod schema or an Effect Schema for - * both `schema` and `busSchema`. Effect Schemas are converted to Zod via the - * `effect-zod` walker since the sync pipeline uses Zod for validation and - * JSON Schema generation. - */ export function define< Type extends string, Agg extends string, - S extends SchemaLike, - B extends BusSchemaLike = S, ->(input: { type: Type; version: number; aggregate: Agg; schema: S; busSchema?: B }) { + Fields extends Schema.Struct.Fields & Record, + BusFields extends Schema.Struct.Fields = Fields, +>(input: { + type: Type + version: number + aggregate: Agg + schema: StructLike + busSchema?: StructLike +}): DefinedEvent, Schema.Struct> { if (frozen) { throw new Error("Error defining sync event: sync system has been frozen") } - const schema = toZodObject(input.schema) as ToZodObject - const properties = (input.busSchema ? toZodObject(input.busSchema) : schema) as ToZodObject + const schema = struct(input.schema) + const busSchema = (input.busSchema ? struct(input.busSchema) : schema) as Schema.Struct + const properties = zod(busSchema) as unknown as z.ZodType> - const def = { + const def: DefinedEvent = { type: input.type, version: input.version, aggregate: input.aggregate, schema, + busSchema, properties, } versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) - registry.set(versionedType(def.type, def.version), def as unknown as Definition) + registry.set(versionedType(def.type, def.version), def) return def } -function toZodObject(value: ZodObject | Schema.Top): z.ZodObject { - if (Schema.isSchema(value)) { - return zod(value as Schema.Top) as unknown as z.ZodObject - } - return value as z.ZodObject -} - export function project( def: Def, func: (db: Database.TxOrDb, data: Event["data"]) => void, @@ -172,10 +171,10 @@ function process(def: Def, event: Event, options: { const result = convertEvent(def.type, event.data) if (result instanceof Promise) { void result.then((data) => { - void ProjectBus.publish({ type: def.type, properties: def.schema }, data) + void ProjectBus.publish({ type: def.type, properties: def.properties }, data) }) } else { - void ProjectBus.publish({ type: def.type, properties: def.schema }, result) + void ProjectBus.publish({ type: def.type, properties: def.properties }, result) } GlobalBus.emit("event", { @@ -295,7 +294,7 @@ export function payloads() { id: z.string(), seq: z.number(), aggregateID: z.literal(def.aggregate), - data: def.schema, + data: zod(def.schema), }) .meta({ ref: `SyncEvent.${def.type}`, diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 81c78132ece4..9c9ff3081894 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -44,13 +44,13 @@ describe("SyncEvent", () => { type: "item.created", version: 1, aggregate: "id", - schema: z.object({ id: z.string(), name: z.string() }), + schema: { id: Schema.String, name: Schema.String }, }) const Sent = SyncEvent.define({ type: "item.sent", version: 1, aggregate: "item_id", - schema: z.object({ item_id: z.string(), to: z.string() }), + schema: { item_id: Schema.String, to: Schema.String }, }) SyncEvent.init({ @@ -139,7 +139,7 @@ describe("SyncEvent", () => { type: "item.effect.created", version: 1, aggregate: "id", - schema: Schema.Struct({ id: Schema.String, name: Schema.String }), + schema: { id: Schema.String, name: Schema.String }, }) SyncEvent.init({ From 53e1d7b8bce1e3a9a661ef6b45f557d22d315ed9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Apr 2026 10:30:54 -0400 Subject: [PATCH 4/4] refactor(core): replace sync event cast with Schema guard Use Schema.is to narrow converted sync events instead of asserting the payload type manually. Move the Zod-to-Effect bridge into effect-zod so remaining Zod-backed sync payloads have one explicit interop helper. --- packages/opencode/src/server/projectors.ts | 6 ++++-- packages/opencode/src/session/session.ts | 10 ++-------- packages/opencode/src/util/effect-zod.ts | 8 ++++++++ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index c32e08693e8f..b5cc446b870e 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -5,12 +5,14 @@ import { Session } from "@/session" import { SessionTable } from "@/session/session.sql" import { Database, eq } from "@/storage" +const isSessionUpdated = Schema.is(Session.Event.Updated.schema) + export function initProjectors() { SyncEvent.init({ projectors: sessionProjectors, convertEvent: (type, data) => { - if (type === "session.updated") { - const id = (data as Schema.Schema.Type).sessionID + if (type === Session.Event.Updated.type && isSessionUpdated(data)) { + const id = data.sessionID const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) if (!row) return data diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 46513801bed2..3b9e2ebb4be2 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -28,7 +28,7 @@ import type { Provider } from "@/provider" import { Permission } from "@/permission" import { Global } from "@/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { ZodOverride, zod, zodObject } from "@/util/effect-zod" +import { fromZod, zod, zodObject } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const log = Log.create({ service: "session" }) @@ -215,13 +215,7 @@ export const MessagesInput = Schema.Struct({ limit: Schema.optional(Schema.Number), }).pipe(withStatics((s) => ({ zod: zod(s) }))) -function schemaFromZod(value: T) { - return Schema.declare((input): input is z.output => value.safeParse(input).success).annotate({ - [ZodOverride]: value, - }) -} - -const SessionUpdateInfoSchema = schemaFromZod( +const SessionUpdateInfoSchema = fromZod( updateSchema(zodObject(Info)).extend({ share: updateSchema(zodObject(Share)).optional(), time: updateSchema(zodObject(Time)).optional(), diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index edbbf4d542c9..e949b255f545 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -49,6 +49,14 @@ function isZodType(value: unknown): value is z.ZodTypeAny { return typeof value === "object" && value !== null && "_zod" in value } +// Bridge a Zod-first schema into Effect Schema while preserving the original +// Zod for downstream JSON Schema/OpenAPI generation. +export function fromZod(value: T) { + return Schema.declare((input): input is z.output => value.safeParse(input).success).annotate({ + [ZodOverride]: value, + }) +} + function walk(ast: SchemaAST.AST): z.ZodTypeAny { const cached = walkCache.get(ast) if (cached) return cached