mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Give each observation a server-issued ID owned by its physical connection. Preserve snapshot-only reads and combined list-and-subscribe, source-bound replies, lifecycle cleanup and legacy wire compatibility.
83 lines
5.0 KiB
Markdown
83 lines
5.0 KiB
Markdown
# Protocol Compatibility
|
|
|
|
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
|
|
|
|
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
|
|
|
|
Two contracts follow from it.
|
|
|
|
## The protocol contract: always compatible
|
|
|
|
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
|
|
|
- New fields are `.optional()` with a sensible default.
|
|
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
|
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
|
- Wire schemas are pure structural declarations. No `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas — normalization happens in an explicit pass after validation. The reason is in [protocol-validation.md](protocol-validation.md): inbound validators are generated, and the generator only compiles pure schemas.
|
|
- Plain `z.union()` is forbidden when every branch shares a literal tag. Use `z.discriminatedUnion()`.
|
|
- `.default()` belongs on primitive leaves only, never on item schemas inside large arrays or big inbound containers.
|
|
|
|
Two questions to ask before you commit a schema change:
|
|
|
|
1. Does a six-month-old app still parse this message?
|
|
2. Does a six-month-old daemon still send something this app accepts?
|
|
|
|
If you can't answer both with yes, the change isn't done.
|
|
|
|
Schemas live in `packages/protocol/src/messages.ts`. New RPC names follow [rpc-namespacing.md](rpc-namespacing.md).
|
|
|
|
## The feature contract: per feature, gated once
|
|
|
|
Features don't have to work across versions. A new feature usually needs a new daemon capability, and old daemons don't have it.
|
|
|
|
The app checks for the capability and either runs the feature or tells the user to update the host.
|
|
|
|
- **No fallback paths.** Don't build a degraded version of the feature for old daemons. Don't fan out across legacy RPCs to simulate a capability that isn't there. The user updates or doesn't get the feature.
|
|
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
|
|
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
|
|
|
|
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
|
|
|
## Client capability ownership
|
|
|
|
The client package advertises the protocol behavior it implements. Add each new capability to
|
|
its exhaustive defaults and implement the associated subscription or decoding behavior there.
|
|
The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser
|
|
automation, or explicit overrides. A schema accepting a message does not establish support for
|
|
its delivery semantics.
|
|
|
|
Connecting creates no timeline or event demand. Client subscriptions own their network membership,
|
|
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
|
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
|
|
|
## Owned observations
|
|
|
|
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the entire source-owned
|
|
contract described in [architecture](architecture.md#websocket-protocol). The client gates observation
|
|
once and tells users to update an older host. It does not emulate independent handles over legacy slots.
|
|
|
|
Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary.
|
|
The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern
|
|
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
|
|
for parsing compatibility; modern requests cannot select their subscription ID.
|
|
|
|
## Every shim is tagged and dated
|
|
|
|
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
|
|
|
```ts
|
|
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
|
```
|
|
|
|
`rg "COMPAT\("` is the full cleanup backlog, so:
|
|
|
|
- One tag per shim, at the site that has to be deleted.
|
|
- Give it a name, a version, and a removal condition or date. Six months out is the usual default.
|
|
- Never bury compatibility in an untagged `??` fallback or an optional-chain tunnel. Untagged back-compat never gets removed, because nobody can find it.
|
|
|
|
When a tag's condition is met, delete the shim and the tag in the same change.
|
|
|
|
## QA
|
|
|
|
Tests don't fully cover compatibility. If you touched `packages/protocol`, say in the pull request why an older app still parses your message and why an older daemon still satisfies your app. See [qa.md](qa.md).
|