refactor(sync): make session events schema-first (#24019)

This commit is contained in:
Kit Langton
2026-04-23 12:43:08 -04:00
committed by GitHub
parent 353532b1c1
commit c50d65b4d6
9 changed files with 141 additions and 81 deletions
+18 -6
View File
@@ -1,5 +1,5 @@
import z from "zod" import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema as EffectSchema, Types } from "effect"
import { EffectBridge } from "@/effect" import { EffectBridge } from "@/effect"
import { Log } from "../util" import { Log } from "../util"
import { BusEvent } from "./bus-event" import { BusEvent } from "./bus-event"
@@ -9,6 +9,12 @@ import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" }) const log = Log.create({ service: "bus" })
type BusProperties<D extends BusEvent.Definition = BusEvent.Definition> = D extends {
effectProperties: infer Properties extends EffectSchema.Top
}
? Types.DeepMutable<EffectSchema.Schema.Type<Properties>>
: z.infer<D["properties"]>
export const InstanceDisposed = BusEvent.define( export const InstanceDisposed = BusEvent.define(
"server.instance.disposed", "server.instance.disposed",
z.object({ z.object({
@@ -18,7 +24,7 @@ export const InstanceDisposed = BusEvent.define(
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = { type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"] type: D["type"]
properties: z.infer<D["properties"]> properties: BusProperties<D>
} }
type State = { type State = {
@@ -29,7 +35,7 @@ type State = {
export interface Interface { export interface Interface {
readonly publish: <D extends BusEvent.Definition>( readonly publish: <D extends BusEvent.Definition>(
def: D, def: D,
properties: z.output<D["properties"]>, properties: BusProperties<D>,
) => Effect.Effect<void> ) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>> readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload> readonly subscribeAll: () => Stream.Stream<Payload>
@@ -79,7 +85,10 @@ export const layer = Layer.effect(
}) })
} }
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) { function publish<D extends BusEvent.Definition>(
def: D,
properties: BusProperties<D>,
) {
return Effect.gen(function* () { return Effect.gen(function* () {
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties } const payload: Payload = { type: def.type, properties }
@@ -175,13 +184,16 @@ const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) { export async function publish<D extends BusEvent.Definition>(
def: D,
properties: BusProperties<D>,
) {
return runPromise((svc) => svc.publish(def, properties)) return runPromise((svc) => svc.publish(def, properties))
} }
export function subscribe<D extends BusEvent.Definition>( export function subscribe<D extends BusEvent.Definition>(
def: D, def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown, callback: (event: Payload<D>) => unknown,
) { ) {
return runSync((svc) => svc.subscribeCallback(def, callback)) return runSync((svc) => svc.subscribeCallback(def, callback))
} }
+1 -2
View File
@@ -1,4 +1,3 @@
import z from "zod"
import sessionProjectors from "../session/projectors" import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { Session } from "@/session" import { Session } from "@/session"
@@ -10,7 +9,7 @@ export function initProjectors() {
projectors: sessionProjectors, projectors: sessionProjectors,
convertEvent: (type, data) => { convertEvent: (type, data) => {
if (type === "session.updated") { if (type === "session.updated") {
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID const id = (data as SyncEvent.Event<typeof Session.Event.Updated>["data"]).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data if (!row) return data
+26 -18
View File
@@ -576,34 +576,46 @@ export const Info = Object.assign(_Info, {
}) })
export type Info = User | Assistant export type Info = User | Assistant
const UpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: _Info,
})
const RemovedEventSchema = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
})
const PartUpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
part: _Part,
time: Schema.Number,
})
const PartRemovedEventSchema = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: PartID,
})
export const Event = { export const Event = {
Updated: SyncEvent.define({ Updated: SyncEvent.define({
type: "message.updated", type: "message.updated",
version: 1, version: 1,
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: UpdatedEventSchema,
sessionID: SessionID.zod,
info: Info.zod,
}),
}), }),
Removed: SyncEvent.define({ Removed: SyncEvent.define({
type: "message.removed", type: "message.removed",
version: 1, version: 1,
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: RemovedEventSchema,
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
}), }),
PartUpdated: SyncEvent.define({ PartUpdated: SyncEvent.define({
type: "message.part.updated", type: "message.part.updated",
version: 1, version: 1,
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: PartUpdatedEventSchema,
sessionID: SessionID.zod,
part: Part.zod,
time: z.number(),
}),
}), }),
PartDelta: BusEvent.define( PartDelta: BusEvent.define(
"message.part.delta", "message.part.delta",
@@ -619,11 +631,7 @@ export const Event = {
type: "message.part.removed", type: "message.part.removed",
version: 1, version: 1,
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: PartRemovedEventSchema,
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
}), }),
} }
+1 -1
View File
@@ -71,7 +71,7 @@ export default [
const info = data.info const info = data.info
const row = db const row = db
.update(SessionTable) .update(SessionTable)
.set(toPartialRow(info)) .set(toPartialRow(info as Session.Patch))
.where(eq(SessionTable.id, data.sessionID)) .where(eq(SessionTable.id, data.sessionID))
.returning() .returning()
.get() .get()
+43 -22
View File
@@ -15,7 +15,6 @@ import { PartTable, SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage" import { Storage } from "@/storage"
import { Log } from "../util" import { Log } from "../util"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
@@ -28,7 +27,7 @@ import type { Provider } from "@/provider"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Global } from "@/global" import { Global } from "@/global"
import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { Effect, Layer, Option, Context, Schema, Types } from "effect"
import { zod, zodObject } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { withStatics } from "@/util/schema"
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -215,40 +214,62 @@ export const MessagesInput = Schema.Struct({
limit: Schema.optional(Schema.Number), limit: Schema.optional(Schema.Number),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
const CreatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: Info,
})
const UpdatedShare = Schema.Struct({
url: Schema.optional(Schema.NullOr(Schema.String)),
})
const UpdatedTime = Schema.Struct({
created: Schema.optional(Schema.NullOr(Schema.Number)),
updated: Schema.optional(Schema.NullOr(Schema.Number)),
compacting: Schema.optional(Schema.NullOr(Schema.Number)),
archived: Schema.optional(Schema.NullOr(Schema.Number)),
})
const UpdatedInfo = Schema.Struct({
id: Schema.optional(Schema.NullOr(SessionID)),
slug: Schema.optional(Schema.NullOr(Schema.String)),
projectID: Schema.optional(Schema.NullOr(ProjectID)),
workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)),
directory: Schema.optional(Schema.NullOr(Schema.String)),
parentID: Schema.optional(Schema.NullOr(SessionID)),
summary: Schema.optional(Schema.NullOr(Summary)),
share: Schema.optional(UpdatedShare),
title: Schema.optional(Schema.NullOr(Schema.String)),
version: Schema.optional(Schema.NullOr(Schema.String)),
time: Schema.optional(UpdatedTime),
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
revert: Schema.optional(Schema.NullOr(Revert)),
})
const UpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: UpdatedInfo,
})
export const Event = { export const Event = {
Created: SyncEvent.define({ Created: SyncEvent.define({
type: "session.created", type: "session.created",
version: 1, version: 1,
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: CreatedEventSchema,
sessionID: SessionID.zod,
info: Info.zod,
}),
}), }),
Updated: SyncEvent.define({ Updated: SyncEvent.define({
type: "session.updated", type: "session.updated",
version: 1, version: 1,
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: UpdatedEventSchema,
sessionID: SessionID.zod, busSchema: CreatedEventSchema,
info: updateSchema(zodObject(Info)).extend({
share: updateSchema(zodObject(Share)).optional(),
time: updateSchema(zodObject(Time)).optional(),
}),
}),
busSchema: z.object({
sessionID: SessionID.zod,
info: Info.zod,
}),
}), }),
Deleted: SyncEvent.define({ Deleted: SyncEvent.define({
type: "session.deleted", type: "session.deleted",
version: 1, version: 1,
aggregate: "sessionID", aggregate: "sessionID",
schema: z.object({ schema: CreatedEventSchema,
sessionID: SessionID.zod,
info: Info.zod,
}),
}), }),
Diff: BusEvent.define( Diff: BusEvent.define(
"session.diff", "session.diff",
@@ -394,7 +415,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
type Patch = z.infer<typeof Event.Updated.schema>["info"] export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["data"]["info"]>
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) => const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn)) Effect.sync(() => Database.use(fn))
+1 -1
View File
@@ -181,7 +181,7 @@ export const layer = Layer.effect(
yield* watch(Session.Event.Updated, (evt) => yield* watch(Session.Event.Updated, (evt) =>
Effect.gen(function* () { Effect.gen(function* () {
const info = yield* session.get(evt.properties.sessionID) const info = evt.properties.info
yield* sync(info.id, [{ type: "session", data: info }]) yield* sync(info.id, [{ type: "session", data: info }])
}), }),
) )
+32 -12
View File
@@ -1,5 +1,4 @@
import z from "zod" import z from "zod"
import type { ZodObject } from "zod"
import { Database, eq } from "@/storage" import { Database, eq } from "@/storage"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus" import { Bus as ProjectBus } from "@/bus"
@@ -9,11 +8,16 @@ import { EventSequenceTable, EventTable } from "./event.sql"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { EventID } from "./schema" import { EventID } from "./schema"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Schema as EffectSchema, Types } from "effect"
import { zodObject } from "@/util/effect-zod"
import { isRecord } from "@/util/record"
export type Definition = { export type Definition<Schema extends EffectSchema.Top = EffectSchema.Top, BusSchema extends EffectSchema.Top = Schema> = {
type: string type: string
version: number version: number
aggregate: string aggregate: string
effectSchema: Schema
effectProperties: BusSchema
schema: z.ZodObject schema: z.ZodObject
// This is temporary and only exists for compatibility with bus // This is temporary and only exists for compatibility with bus
@@ -25,9 +29,13 @@ export type Event<Def extends Definition = Definition> = {
id: string id: string
seq: number seq: number
aggregateID: string aggregateID: string
data: z.infer<Def["schema"]> data: Types.DeepMutable<EffectSchema.Schema.Type<Def["effectSchema"]>>
} }
export type Properties<Def extends Definition = Definition> = Types.DeepMutable<
EffectSchema.Schema.Type<Def["effectProperties"]>
>
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string } export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
@@ -36,7 +44,12 @@ export const registry = new Map<string, Definition>()
let projectors: Map<Definition, ProjectorFunc> | undefined let projectors: Map<Definition, ProjectorFunc> | undefined
const versions = new Map<string, number>() const versions = new Map<string, number>()
let frozen = false let frozen = false
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown> let convertEvent: (type: string, event: Event["data"]) => Promise<unknown> | unknown
function asRecord(input: unknown) {
if (isRecord(input)) return input
throw new Error(`SyncEvent.convertEvent must return an object, got: ${JSON.stringify(input)}`)
}
export function reset() { export function reset() {
frozen = false frozen = false
@@ -54,7 +67,7 @@ export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; co
for (let [type, version] of versions.entries()) { for (let [type, version] of versions.entries()) {
let def = registry.get(versionedType(type, version))! 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 // Freeze the system so it clearly errors if events are defined
@@ -72,19 +85,26 @@ export function versionedType(type: string, version?: number) {
export function define< export function define<
Type extends string, Type extends string,
Agg extends string, Agg extends string,
Schema extends ZodObject<Record<Agg, z.ZodType<string>>>, Schema extends EffectSchema.Top,
BusSchema extends ZodObject = Schema, BusSchema extends EffectSchema.Top = Schema,
>(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) { >(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }): Definition<
Schema,
BusSchema
> {
if (frozen) { if (frozen) {
throw new Error("Error defining sync event: sync system has been frozen") throw new Error("Error defining sync event: sync system has been frozen")
} }
const effectProperties = (input.busSchema ?? input.schema) as BusSchema
const def = { const def = {
type: input.type, type: input.type,
version: input.version, version: input.version,
aggregate: input.aggregate, aggregate: input.aggregate,
schema: input.schema, effectSchema: input.schema,
properties: input.busSchema ? input.busSchema : input.schema, effectProperties,
schema: zodObject(input.schema),
properties: zodObject(effectProperties),
} }
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
@@ -143,10 +163,10 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
const result = convertEvent(def.type, event.data) const result = convertEvent(def.type, event.data)
if (result instanceof Promise) { if (result instanceof Promise) {
void result.then((data) => { void result.then((data) => {
void ProjectBus.publish({ type: def.type, properties: def.schema }, data) void ProjectBus.publish({ type: def.type, properties: def.properties }, asRecord(data))
}) })
} else { } else {
void ProjectBus.publish({ type: def.type, properties: def.schema }, result) void ProjectBus.publish({ type: def.type, properties: def.properties }, asRecord(result))
} }
GlobalBus.emit("event", { GlobalBus.emit("event", {
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test" import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import z from "zod" import { Schema } from "effect"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SyncEvent } from "../../src/sync" import { SyncEvent } from "../../src/sync"
@@ -43,13 +43,13 @@ describe("SyncEvent", () => {
type: "item.created", type: "item.created",
version: 1, version: 1,
aggregate: "id", aggregate: "id",
schema: z.object({ id: z.string(), name: z.string() }), schema: Schema.Struct({ id: Schema.String, name: Schema.String }),
}) })
const Sent = SyncEvent.define({ const Sent = SyncEvent.define({
type: "item.sent", type: "item.sent",
version: 1, version: 1,
aggregate: "item_id", aggregate: "item_id",
schema: z.object({ item_id: z.string(), to: z.string() }), schema: Schema.Struct({ item_id: Schema.String, to: Schema.String }),
}) })
SyncEvent.init({ SyncEvent.init({
+16 -16
View File
@@ -1058,31 +1058,31 @@ export type SyncEventSessionUpdated = {
data: { data: {
sessionID: string sessionID: string
info: { info: {
id: string | null id?: string | null
slug: string | null slug?: string | null
projectID: string | null projectID?: string | null
workspaceID: string | null workspaceID?: string | null
directory: string | null directory?: string | null
parentID: string | null parentID?: string | null
summary: { summary?: {
additions: number additions: number
deletions: number deletions: number
files: number files: number
diffs?: Array<SnapshotFileDiff> diffs?: Array<SnapshotFileDiff>
} | null } | null
share?: { share?: {
url: string | null url?: string | null
} }
title: string | null title?: string | null
version: string | null version?: string | null
time?: { time?: {
created: number | null created?: number | null
updated: number | null updated?: number | null
compacting: number | null compacting?: number | null
archived: number | null archived?: number | null
} }
permission: PermissionRuleset | null permission?: PermissionRuleset | null
revert: { revert?: {
messageID: string messageID: string
partID?: string partID?: string
snapshot?: string snapshot?: string