Bug fixes

This commit is contained in:
James Long
2026-03-23 17:44:26 -04:00
parent a61f809ea6
commit 7bfbdae444
5 changed files with 85 additions and 46 deletions
+9 -4
View File
@@ -2,7 +2,8 @@ import z from "zod"
import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
let initialized = false
@@ -16,10 +17,14 @@ export function initProjectors() {
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const sessionID = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
return {
sessionID: SessionID.zod,
info: Session.get(sessionID),
sessionID: id,
info: Session.fromRow(row),
}
}
return data
+9 -5
View File
@@ -16,6 +16,7 @@ import { SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { SessionPrompt } from "./prompt"
@@ -198,9 +199,9 @@ export namespace Session {
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info.partial().extend({
share: Info.shape.share.unwrap().partial().optional(),
time: Info.shape.time.partial().optional(),
info: updateSchema(Info).extend({
share: updateSchema(Info.shape.share.unwrap()).optional(),
time: updateSchema(Info.shape.time).optional(),
}),
}),
busSchema: z.object({
@@ -378,7 +379,7 @@ export namespace Session {
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: undefined } } })
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })
})
export const setTitle = fn(
@@ -437,7 +438,7 @@ export namespace Session {
sessionID,
info: {
time: { updated: Date.now() },
revert: undefined,
revert: null,
},
})
})
@@ -615,6 +616,9 @@ export namespace Session {
await unshare(sessionID).catch(() => {})
SyncEvent.run(Event.Deleted, { sessionID, info: session })
// Eagerly remove event sourcing data to free up space
SyncEvent.remove(sessionID)
} catch (e) {
log.error(e)
}
+7 -2
View File
@@ -5,7 +5,7 @@ import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
function grab<T extends object, K1 extends keyof T, X>(
obj: T,
@@ -18,7 +18,12 @@ function grab<T extends object, K1 extends keyof T, X>(
if (val && typeof val === "object" && cb) {
return cb(val)
}
return (val === undefined ? null : val) as X | undefined
if (val === undefined) {
throw new Error(
"Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
)
}
return val as X | undefined
}
export function toPartialRow(info: DeepPartial<Session.Info>) {
+47 -35
View File
@@ -35,14 +35,11 @@ export namespace SyncEvent {
let projectors: Map<Definition, ProjectorFunc> | undefined
const versions = new Map<string, number>()
let frozen = false
let convertEvent: ((type: string, event: Event["data"]) => Record<string, unknown>) | undefined
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>()
export function init(input: {
projectors: Array<[Definition, ProjectorFunc]>
convertEvent?: Exclude<typeof convertEvent, undefined>
}) {
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
projectors = new Map(input.projectors)
// Install all the latest event defs to the bus. We only ever emit
@@ -58,7 +55,7 @@ export namespace SyncEvent {
// Freeze the system so it clearly errors if events are defined
// after `init` which would cause bugs
frozen = true
convertEvent = input.convertEvent
convertEvent = input.convertEvent || ((_, data) => data)
}
export function versionedType<A extends string>(type: A): A
@@ -99,7 +96,7 @@ export namespace SyncEvent {
return [def, func as ProjectorFunc]
}
function process<Def extends Definition>(def: Def, input: Event<Def>) {
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
if (projectors == null) {
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
}
@@ -112,29 +109,47 @@ export namespace SyncEvent {
// idempotent: need to ignore any events already logged
Database.transaction((tx) => {
projector(tx, input.data)
projector(tx, event.data)
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
tx.insert(EventSequenceTable)
.values({
aggregate_id: input.aggregateID,
seq: input.seq,
aggregate_id: event.aggregateID,
seq: event.seq,
})
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: input.seq },
set: { seq: event.seq },
})
.run()
tx.insert(EventTable)
.values({
id: input.id,
seq: input.seq,
aggregate_id: input.aggregateID,
name: def.type,
data: input.data as Record<string, unknown>,
id: event.id,
seq: event.seq,
aggregate_id: event.aggregateID,
name: versionedType(def.type, def.version),
data: event.data as Record<string, unknown>,
})
.run()
}
Database.effect(() => {
Bus.emit("event", {
def,
event,
})
if (options?.publish) {
const result = convertEvent(def.type, event.data)
if (result instanceof Promise) {
result.then((data) => {
ProjectBus.publish({ type: def.type, properties: def.schema }, data)
})
} else {
ProjectBus.publish({ type: def.type, properties: def.schema }, result)
}
}
})
})
}
@@ -144,7 +159,7 @@ export namespace SyncEvent {
// and it validets all the sequence ids
// * when loading events from db, apply zod validation to ensure shape
export function replay(event: SerializedEvent) {
export function replay(event: SerializedEvent, options?: { republish: boolean }) {
const def = registry.get(event.type)
if (!def) {
throw new Error(`Unknown event type: ${event.type}`)
@@ -158,12 +173,17 @@ export namespace SyncEvent {
.get(),
)
const expected = row ? row.seq + 1 : 0
const latest = row?.seq ?? -1
if (event.seq <= latest) {
return
}
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
}
process(def, event)
process(def, event, { publish: !!options?.republish })
}
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"]) {
@@ -192,22 +212,7 @@ export namespace SyncEvent {
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event)
Database.effect(() => {
Bus.emit("event", {
def,
event,
})
ProjectBus.publish(
{
type: def.type,
properties: def.schema,
},
convertEvent ? convertEvent(def.type, event.data) : event.data,
)
})
process(def, event, { publish: true })
},
{
behavior: "immediate",
@@ -215,6 +220,13 @@ export namespace SyncEvent {
)
}
export function remove(aggregateID: string) {
Database.transaction((tx) => {
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
})
}
export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) {
Bus.on("event", handler)
return () => Bus.off("event", handler)
@@ -0,0 +1,13 @@
import z from "zod"
export function updateSchema<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
const next = {} as {
[K in keyof T]: z.ZodOptional<z.ZodNullable<T[K]>>
}
for (const [k, v] of Object.entries(schema.required().shape) as [keyof T & string, z.ZodTypeAny][]) {
next[k] = v.nullable() as unknown as (typeof next)[typeof k]
}
return z.object(next)
}