fix(bus): tighten GlobalBus payload and BusEvent.define types

Constrain BusEvent.define to ZodObject instead of ZodType so TS knows
event properties are always a record. Type GlobalBus payload as
{ type: string; properties: Record<string, unknown> } instead of any.

Refactor watcher test to use Bus.subscribe instead of raw GlobalBus
listener, removing hand-rolled event types and unnecessary casts.
This commit is contained in:
Kit Langton 2026-03-18 20:57:08 -04:00
commit f63a2a2636
4 changed files with 11 additions and 17 deletions

View file

@ -1,5 +1,5 @@
import z from "zod" import z from "zod"
import type { ZodType } from "zod" import type { ZodObject, ZodRawShape } from "zod"
import { Log } from "../util/log" import { Log } from "../util/log"
export namespace BusEvent { export namespace BusEvent {
@ -9,7 +9,7 @@ export namespace BusEvent {
const registry = new Map<string, Definition>() const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) { export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
const result = { const result = {
type, type,
properties, properties,

View file

@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
event: [ event: [
{ {
directory?: string directory?: string
payload: any payload: { type: string; properties: Record<string, unknown> }
}, },
] ]
}>() }>()

View file

@ -123,7 +123,7 @@ export namespace Workspace {
await parseSSE(res.body, stop, (event) => { await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: space.id, directory: space.id,
payload: event, payload: event as { type: string; properties: Record<string, unknown> },
}) })
}) })
// Wait 250ms and retry if SSE connection fails // Wait 250ms and retry if SSE connection fails

View file

@ -5,9 +5,9 @@ import path from "path"
import { Deferred, Effect, Option } from "effect" import { Deferred, Effect, Option } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { Bus } from "../../src/bus"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */ /** Run `body` with a live FileWatcher service. */
@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) { function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false let done = false
function on(evt: BusUpdate) { const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
if (done) return if (done) return
if (evt.directory !== directory) return if (!check(evt.properties)) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return hit(evt.properties)
if (!check(evt.payload.properties)) return })
hit(evt.payload.properties)
}
function cleanup() { return () => {
if (done) return if (done) return
done = true done = true
GlobalBus.off("event", on) unsub()
} }
GlobalBus.on("event", on)
return cleanup
} }
function wait(directory: string, check: (evt: WatcherEvent) => boolean) { function wait(directory: string, check: (evt: WatcherEvent) => boolean) {