refactor(bus): one GlobalBus emit per event, with optional sync metadata#28203
Draft
kitlangton wants to merge 1 commit into
Draft
refactor(bus): one GlobalBus emit per event, with optional sync metadata#28203kitlangton wants to merge 1 commit into
kitlangton wants to merge 1 commit into
Conversation
Today every sync.run produces TWO GlobalBus events:
1. From bus.publish itself — the projection view:
{ payload: { id, type: "session.created", properties } }
2. From sync/index.ts — the source-of-truth envelope:
{ payload: { type: "sync", syncEvent: { type: "session.created.1", id, seq, aggregateID, data } } }
These two emits live in different files and discriminate via
`payload.type` — either a real event type or the sentinel "sync".
That collision is the source of confusion in the codebase:
consumers have to know which shape they're looking at.
The duality dates back to:
- d88912a (Dec 2025): Dax added GlobalBus.emit inside bus.publish.
- b22add2 (#22347, Apr 2026): James consolidated sync events onto
GlobalBus by ADDING a second emit with the source envelope.
#22347's stated intent was "consolidate events into a single stream".
Two emits with different shapes on one channel isn't quite that. One
emit per event with a unified shape is.
This PR collapses the two emits into one:
{ payload: { id, type: "session.created", properties, sync?: { name, seq, aggregateID, data } } }
- bus.publish accepts an optional `sync?: SyncMetadata` option.
- sync/index.ts passes it; its own GlobalBus.emit is removed.
- BusEvent.effectPayloads() adds an optional `sync` field to every
event schema.
- SyncEvent.effectPayloads() is no longer included in the wire schema
(it's now subsumed by the optional field).
- Consumers migrate from `payload.type === "sync"` filtering to
`payload.sync != null` filtering. control-plane/workspace.ts
reconstructs SerializedEvent from { id, sync.* } for replay.
Note: this is a BREAKING SDK wire-format change. External consumers
that read `payload.type === "sync"` or `payload.syncEvent` need to
migrate. Internal opencode consumers all migrated in this PR.
Side observation: the runtime emit was using `payload.syncEvent`
(nested) while the SDK schema declared the fields at top level under
`type: "sync"` — a silent schema drift. Collapsing to one emit also
fixes that drift by definition.
Verified:
- bun typecheck — clean
- bun run test test/sync/index.test.ts test/bus/bus-effect.test.ts
test/server/httpapi-event.test.ts
test/server/httpapi-event-diagnostics.test.ts — 29/29 green
- bun run test test/server/httpapi-sdk.test.ts -t "streams sync-backed" — green
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DRAFT / PROTOTYPE — opening this to evaluate the shape of the change. Builds on #27959 (bus eager-subscribe) and is orthogonal to #28187 (sync EffectBridge).
The problem
Today every
sync.runproduces twoGlobalBusevents with two different payload shapes:bus.publishitself — the projection view:sync/index.ts— the source-of-truth envelope:Consumers discriminate via
payload.type— either a real event type or the sentinel"sync". The two emits live in different files. The dual-emit is the source of "wait, which payload is this?" confusion.History
d88912abf0(Dec 2025): Dax addedGlobalBus.emitinsidebus.publish.b22add292c(refactor(core): publish sync events to global event stream #22347, Apr 2026): James consolidated sync events ontoGlobalBusby adding a second emit with the source envelope. Stated intent: "consolidate events into a single stream."Two emits with different shapes on one channel isn't quite a single stream. One emit per event with a unified shape is.
What this PR does
Collapses the two emits into one:
bus.publishaccepts an optionalsync?: SyncMetadataoption.sync/index.tspasses it; the secondGlobalBus.emitis removed.BusEvent.effectPayloads()adds an optionalsyncfield to every event schema.SyncEvent.effectPayloads()is no longer included in the wire schema (subsumed).payload.type === \"sync\"topayload.sync != null.workspace.tsreconstructsSerializedEventfrom{ id, sync.* }for replay.Breaking change
This is a breaking SDK wire-format change. External consumers reading `payload.type === "sync"` or `payload.syncEvent` will need to migrate.
Inside opencode, all internal consumers were migrated in this PR:
Side-note: schema drift fixed
While doing this I noticed the runtime emit was using `payload.syncEvent` (nested) while the SDK schema (`SyncEvent.effectPayloads()`) declared the fields at top level under `type: "sync"`. They had drifted. Collapsing to one emit fixes this by definition.
Test plan
Net diff
~10 files. Includes regenerated SDK types.
Why draft
This is a wire-protocol change. Worth a deliberate decision on:
Happy to convert to ready-for-review or close, whichever you prefer.