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 efaed94406..bb9b3f497f 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 980dd4da84..7664b09ada 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 125d8c9550..6fdad16621 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 bb9b3f497f..90a0668dba 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 6fdad16621..fdedff0366 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 3df179787d..7cde38a014 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 866bcaa31a..81c78132ec 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 | 81 +++++++++++----------- packages/opencode/test/sync/index.test.ts | 6 +- 4 files changed, 76 insertions(+), 67 deletions(-) diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index cfecce5265..c32e08693e 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 d2bdbccb7d..46513801be 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 fdedff0366..2248ed15d2 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 - - // This is temporary and only exists for compatibility with bus - // event definitions - properties: z.ZodObject + schema: Schema.Top + busSchema: Schema.Top + properties: z.ZodTypeAny } +type MutableType = Types.DeepMutable> + +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> +function struct(value: StructLike) { + return (Schema.isSchema(value) ? value : Schema.Struct(value as Fields)) as 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, - 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 81c78132ec..9c9ff30818 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 c32e08693e..b5cc446b87 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 46513801be..3b9e2ebb4b 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 edbbf4d542..e949b255f5 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