Add SyncEvent service (#25158)

This commit is contained in:
Kit Langton
2026-04-30 16:45:26 -04:00
committed by GitHub
parent 3250b814ce
commit fbcbd24063
11 changed files with 430 additions and 350 deletions

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import fs from "node:fs/promises"
import Http from "node:http"
import path from "node:path"
@@ -1426,48 +1426,41 @@ describe("workspace-old sessionRestore", () => {
})
})
test("local restore replays batches without fetch and emits progress", async () => {
await withInstance(async (dir) => {
const captured = captureGlobalEvents()
let fetchCallCount = 0
const replayAll = spyOn(SyncEvent, "replayAll")
try {
using server = Bun.serve({
port: 0,
fetch() {
fetchCallCount++
return Response.json({ ok: true })
},
})
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
const session = await AppRuntime.runPromise(
SessionNs.Service.use((svc) => svc.create({ title: "restore local" })),
)
replaceSessionEvents(session.id, 20)
it.live("local restore replays batches and emits progress", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20)
expect(await restoreWorkspaceSession({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 })
expect(fetchCallCount).toBe(0)
expect(replayAll).toHaveBeenCalledTimes(3)
expect(replayAll.mock.calls.map((call) => call[0].length)).toEqual([10, 10, 1])
expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(session.id)))).workspaceID).toBe(
info.id,
)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
.filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
await removeWorkspace(info.id)
} finally {
captured.dispose()
}
})
})
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 3,
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
),
)
it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = []

View File

@@ -1,16 +1,18 @@
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Schema } from "effect"
import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { provideTmpdirInstance } from "../fixture/fixture"
import { Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { SyncEvent } from "../../src/sync"
import { Database } from "@/storage/db"
import { EventTable } from "../../src/sync/event.sql"
import { Identifier } from "../../src/id/id"
import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors"
import { testEffect } from "../lib/effect"
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer))
beforeEach(() => {
Database.close()
@@ -22,19 +24,6 @@ afterEach(() => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
})
function withInstance(fn: () => void | Promise<void>) {
return async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fn()
},
})
}
}
describe("SyncEvent", () => {
function setup() {
SyncEvent.reset()
@@ -59,179 +48,209 @@ describe("SyncEvent", () => {
return { Created, Sent }
}
function expectDefect<A, E, R>(effect: Effect.Effect<A, E, R>, pattern: RegExp) {
return Effect.gen(function* () {
const exit = yield* Effect.exit(effect)
if (exit._tag === "Success") throw new Error("Expected effect to fail")
expect(String(exit.cause)).toMatch(pattern)
})
}
afterAll(() => {
SyncEvent.reset()
initProjectors()
})
describe("run", () => {
test(
it.live(
"inserts event row",
withInstance(() => {
const { Created } = setup()
SyncEvent.run(Created, { id: "evt_1", name: "first" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].type).toBe("item.created.1")
expect(rows[0].aggregate_id).toBe("evt_1")
}),
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].type).toBe("item.created.1")
expect(rows[0].aggregate_id).toBe("evt_1")
}),
),
)
test(
it.live(
"increments seq per aggregate",
withInstance(() => {
const { Created } = setup()
SyncEvent.run(Created, { id: "evt_1", name: "first" })
SyncEvent.run(Created, { id: "evt_1", name: "second" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(2)
expect(rows[1].seq).toBe(rows[0].seq + 1)
}),
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "second" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(2)
expect(rows[1].seq).toBe(rows[0].seq + 1)
}),
),
)
test(
it.live(
"uses custom aggregate field from agg()",
withInstance(() => {
const { Sent } = setup()
SyncEvent.run(Sent, { item_id: "evt_1", to: "james" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe("evt_1")
}),
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Sent } = setup()
yield* SyncEvent.use.run(Sent, { item_id: "evt_1", to: "james" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe("evt_1")
}),
),
)
test(
it.live(
"emits events",
withInstance(async () => {
const { Created } = setup()
const events: Array<{
type: string
properties: { id: string; name: string }
}> = []
const received = new Promise<void>((resolve) => {
Bus.subscribeAll((event) => {
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const events: Array<{
type: string
properties: { id: string; name: string }
}> = []
let resolve = () => {}
const received = new Promise<void>((done) => {
resolve = done
})
const dispose = Bus.subscribeAll((event) => {
events.push(event)
resolve()
})
})
SyncEvent.run(Created, { id: "evt_1", name: "test" })
await received
expect(events).toHaveLength(1)
expect(events[0]).toEqual({
type: "item.created",
properties: {
id: "evt_1",
name: "test",
},
})
}),
try {
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" })
yield* Effect.promise(() => received)
expect(events).toHaveLength(1)
expect(events[0]).toEqual({
type: "item.created",
properties: {
id: "evt_1",
name: "test",
},
})
} finally {
dispose()
}
}),
),
)
})
describe("replay", () => {
test(
it.live(
"inserts event from external payload",
withInstance(() => {
const id = Identifier.descending("message")
SyncEvent.replay({
id: "evt_1",
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "replayed" },
})
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe(id)
}),
)
test(
"throws on sequence mismatch",
withInstance(() => {
const id = Identifier.descending("message")
SyncEvent.replay({
id: "evt_1",
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "first" },
})
expect(() =>
SyncEvent.replay({
provideTmpdirInstance(() =>
Effect.gen(function* () {
const id = MessageID.ascending()
yield* SyncEvent.use.replay({
id: "evt_1",
type: "item.created.1",
seq: 5,
aggregateID: id,
data: { id, name: "bad" },
}),
).toThrow(/Sequence mismatch/)
}),
)
test(
"throws on unknown event type",
withInstance(() => {
expect(() =>
SyncEvent.replay({
id: "evt_1",
type: "unknown.event.1",
seq: 0,
aggregateID: "x",
data: {},
}),
).toThrow(/Unknown event type/)
}),
aggregateID: id,
data: { id, name: "replayed" },
})
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe(id)
}),
),
)
test(
"replayAll accepts later chunks after the first batch",
withInstance(() => {
const { Created } = setup()
const id = Identifier.descending("message")
const one = SyncEvent.replayAll([
{
it.live(
"throws on sequence mismatch",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const id = MessageID.ascending()
yield* SyncEvent.use.replay({
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "second" },
},
])
})
yield* expectDefect(
SyncEvent.use.replay({
id: "evt_1",
type: "item.created.1",
seq: 5,
aggregateID: id,
data: { id, name: "bad" },
}),
/Sequence mismatch/,
)
}),
),
)
const two = SyncEvent.replayAll([
{
id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 2,
aggregateID: id,
data: { id, name: "third" },
},
{
id: "evt_4",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 3,
aggregateID: id,
data: { id, name: "fourth" },
},
])
it.live(
"throws on unknown event type",
provideTmpdirInstance(() =>
Effect.gen(function* () {
yield* expectDefect(
SyncEvent.use.replay({
id: "evt_1",
type: "unknown.event.1",
seq: 0,
aggregateID: "x",
data: {},
}),
/Unknown event type/,
)
}),
),
)
expect(one).toBe(id)
expect(two).toBe(id)
it.live(
"replayAll accepts later chunks after the first batch",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
const one = yield* SyncEvent.use.replayAll([
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "second" },
},
])
const two = yield* SyncEvent.use.replayAll([
{
id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 2,
aggregateID: id,
data: { id, name: "third" },
},
{
id: "evt_4",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 3,
aggregateID: id,
data: { id, name: "fourth" },
},
])
expect(one).toBe(id)
expect(two).toBe(id)
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
),
)
})
})