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:
- d88912abf0 (Dec 2025): Dax added GlobalBus.emit inside bus.publish.
- b22add292c (#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
Usage
Dependencies for these templates are managed with pnpm using pnpm up -Lri.
This is the reason you see a pnpm-lock.yaml. That said, any package manager will work. This file can safely be removed once you clone a template.
$ npm install # or pnpm install or yarn install
Learn more on the Solid Website and come chat with us on our Discord
Available Scripts
In the project directory, you can run:
npm run dev or npm start
Runs the app in the development mode.
Open http://localhost:3000 to view it in the browser.
The page will reload if you make edits.
npm run build
Builds the app for production to the dist folder.
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.
Your app is ready to be deployed!
E2E Testing
Playwright starts the Vite dev server automatically via webServer, and UI tests expect an opencode backend at localhost:4096 by default.
bunx playwright install chromium
bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
Environment options:
PLAYWRIGHT_SERVER_HOST/PLAYWRIGHT_SERVER_PORT(backend address, default:localhost:4096)PLAYWRIGHT_PORT(Vite dev server port, default:3000)PLAYWRIGHT_BASE_URL(override base URL, default:http://localhost:<PLAYWRIGHT_PORT>)
Deployment
You can deploy the dist folder to any static host provider (netlify, surge, now, etc.)