Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

257 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# gorp
A small state-sync primitive. Three core classes:
- **`GorpClient`** — leaf-side replica with an optimistic view layered on top
of the latest server-confirmed state.
- **`GorpServer`** — authoritative state container + command handler + op
fan-out. No sessions; no dedup. Pure pubsub.
- **`GorpRelay`** — state mirror + upstream pipe. Same shape as `GorpServer`
from the outside (state + receive + subscribe), but driven by upstream
`GorpMessage`s instead of a local handler.
Plus one layer that turns either into a sessioned wire protocol:
- **`GorpSessions`** — wraps a `GorpServer` or `GorpRelay`. Owns per-`sessionId`
dedup, per-client `ack` flushing, and the in-flight FIFO that routes the
inner's "I processed N more commands" signal back to the right sessions.
Wire protocol:
- **Server → Client** is `GorpMessage = { ops, ack? }`. `ops` is a deep patch
list; `ack` is the per-session high-water seq the inner has processed.
Idempotent — receiving the same `ack` twice is harmless.
- **Client → Server** is one command at a time. Sequence numbers are
*implicit* (positional); the connection handshake conveys `sessionId` and
`fromSeq` (the seq of the next command in the client's pending queue) so
the session wrapper can dedup replays after a reconnect.
- **Initial snapshot**: every connection assumes the client starts with no
server-confirmed state. The server's first message on each connection is
a `set [] <full state>` op, optionally accompanied by an `ack` covering
pending the session has already processed.
---
## `GorpClient<T, C>`
Maintains a `committed` replica that follows the server, and an `optimistic`
view that equals `committed + replay(pending)`. Every public mutation opens
a new diff frame; consumers query `isChangedAt` / `getChangedKeys`
synchronously after `apply()` or `send()` to learn what moved.
```ts
new GorpClient<T, C>(config: GorpClient.Config<T, C>)
namespace GorpClient {
type Config<T, C> = {
initialState: T;
mutator: (state: T, command: C, seq: number) => void;
send: (command: C) => void;
};
}
client.state: DeepReadonly<T>
client.pending: readonly C[]
client.firstPendingSeq: number
client.send(command: C): void
client.apply(message: GorpMessage): void
client.resync(): void
client.onChange(cb: () => void): () => void
client.isChangedAt(path: string[]): boolean
client.getChangedKeys(path: string[]): string[]
```
`mutator` has the same `(state, command, seq) => void` shape `GorpServer`
takes, so both sides can share a single function definition. It runs once
per `send` and again on every replay against fresh committed state —
must be deterministic. `seq` is stable across replays — derive any
optimistic ids from it.
`send` is the outbound transport callback. Gorp invokes it for every
`send(cmd)` call and re-invokes it for each pending entry on `resync()`.
---
## `GorpServer<T, C>`
Authoritative state container. No sessions. Pair with `GorpSessions` if you
want the sessioned wire protocol.
```ts
new GorpServer<T, C>(config: GorpServer.Config<T, C>)
namespace GorpServer {
type Config<T, C> = {
initialState: T;
mutator: (state: T, command: C, seq: number) => void;
};
}
server.state: T // live mutable proxy
server.state = newValue // root replace
server.receive(command: C): void // run the mutator
server.subscribe(cb: (env: GorpMessage) => void): () => void
```
`mutator`'s signature matches `GorpClient` — the `state` arg is the same
live proxy as `server.state`, and `seq` is a per-server monotonic counter
that increments on every `receive`. Server mutators never replay, so
`seq` here is just an id; it's not meaningful the way the client's
replay-stable seq is.
`state` is a live mutable proxy. Property writes queue ops (batched per
microtask); assigning to `state` itself emits a single `set [] value` op.
Mutating array methods (`push`, `pop`, `splice`, …) throw — use
`state.list = [...state.list, x]`. In dev (`NODE_ENV !== "production"`) the
proxy also throws if you try to assign a value that contains a Gorp proxy
(catches the silent infinite-recursion trap when a `state.queue =
arr.filter(...)` would have stored sub-proxies).
`subscribe` fires once per microtask flush with the buffered `ops` and a
cumulative `ack` count (0-indexed: `ack: -1` means "no commands done yet",
`ack: 0` means "one done"). For `GorpServer`, `ack` bumps by 1 on each
`receive` — the handler runs synchronously, ops + ack land in the same
envelope. Used by `GorpSessions` to drain its in-flight FIFO; standalone
consumers can ignore it.
---
## `GorpRelay<T, C>`
State mirror + upstream pipe. Same `state`/`receive`/`subscribe` surface as
`GorpServer`, but state changes come from upstream `GorpMessage` envelopes
rather than a local handler. Pure pass-through — no in-flight tracking, no
resume protocol.
```ts
new GorpRelay<T, C>(config: GorpRelay.Config<T, C>)
namespace GorpRelay {
type Config<T, C> = {
initialState: T;
send: (command: C) => void; // upstream transport callback
};
}
relay.state: DeepReadonly<T>
relay.receive(command: C): void // forward upstream via config.send
relay.applyUpstream(msg: GorpMessage): void // inbound from upstream
relay.subscribe(cb: (env: GorpMessage) => void): () => void
relay.serialize(): RelaySerializedState<T> // { state }
relay.restore(state: RelaySerializedState<T>): void
```
`receive` just forwards via `config.send`. `applyUpstream` applies ops
into the mirror and republishes the envelope to subscribers verbatim
(synchronously — no microtask delay). `ack` flows through as-is; the
wrapping `GorpSessions` interprets it.
**Assumption**: the upstream connection (DO↔Sandbox in the harness) is
stable across DO hibernation. CF Durable Objects with hibernation-aware
sockets buffer messages while idle, so on wake the relay resumes without
an explicit handshake. If the connection ever drops for real, in-flight
commands are lost — application-level idempotency is the recovery story.
---
## `GorpSessions<C>`
Wraps either a `GorpServer` or `GorpRelay` and adds the sessioned wire
protocol.
```ts
new GorpSessions<C>(inner: GorpServer<T, C> | GorpRelay<T, C>)
sessions.addClient(
sessionId: string,
fromSeq: number,
send: (msg: GorpMessage) => void,
): { receive(command: C): void; remove(): void }
sessions.sessions: ReadonlyMap<string, GorpSession> // inspection
sessions.close(): void
sessions.serialize(): GorpSessionsState
sessions.restore(state: GorpSessionsState): void
```
On `addClient`, the wrapper:
1. Touches/creates the per-`sessionId` entry (`{ highWater, lastActivity }`).
2. Sends an initial envelope — `{ ops: [{ set [] state }], ack? }` — so a
reconnecting client splices any already-processed pending without a
round-trip.
On `handle.receive(cmd)`, the wrapper:
1. Assigns the implicit seq from the per-connection counter.
2. Dedups against `lastForwardedSeq`. If new, pushes
`{ sessionId, browserSeq }` onto its in-flight FIFO and calls
`inner.receive(cmd)`.
On the inner's `subscribe` callback, the wrapper:
1. Computes `delta = env.ack _lastSeenAck` (where `_lastSeenAck`
defaults to `-1`).
2. Drains `delta` entries off the front of the FIFO, advancing each
drained session's `highWater` to its `browserSeq`. (`splice(0, delta)`
auto-clamps, so an over-large delta against an empty FIFO is a no-op.)
3. Fans out `ops` to every connected client, attaching the latest
`session.highWater` as `ack` if it advanced since the previous flush.
Persistence: `serialize` returns just `{ sessions }`. `outgoing` is *not*
persisted — the inner is expected to ack on receipt (`GorpServer.receive`
bumps the ack synchronously), so the FIFO drains fast enough that DOs
hibernate idle. `_lastSeenAck` isn't persisted either; on wake it
defaults to `-1` and the first envelope's clamped splice corrects the
math.
Sessions are retained for 1 hour after their last activity; active
(currently-connected) sessions are never pruned.
---
## `appendText`
Marker for the `append-text` op type, for streaming text concatenation
without retransmitting prior content.
```ts
server.state.messages[id].parts[0].text = appendText(chunk);
```
Works inside `GorpClient`'s optimistic mutator too.
---
## `GorpMessage`
```ts
type GorpMessage = {
ops: GorpOperation[];
ack?: number;
};
```
Server-to-client envelope. Apps don't construct these by hand — they fall
out of `GorpServer` / `GorpRelay` subscribers (typically wrapped by
`GorpSessions`).
---
## Reconnection contract
Client transport responsibilities:
1. Stable `sessionId` for the lifetime of the app instance.
2. `?sessionId=…&fromSeq=…` on the WS handshake. `fromSeq` is
`client.firstPendingSeq` at the moment of the handshake — query fresh
on each (re)connect attempt.
3. After `open`, re-send every command in `client.pending` in order.
Duplicates dedup at the server's `GorpSessions` wrapper.
Server-side: persist `relay.serialize()` (or just `sessions.serialize()`
for a non-relay server) before hibernation; restore on wake. If you're
wrapping a `GorpRelay`, persist both blobs together so the upstream
pending queue and the wrapper's `outgoing` FIFO stay in lockstep.