Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,23 @@ While pre-1.0, the public API may change between 0.x releases.

## [Unreleased]

### Changed
### Added

- **Typed oversize-frame handling (ADR-0018; part of
[#28](https://github.com/grrowl/tanstack-durable-object-sync/issues/28)).**
An oversize client mutation used to be dropped silently server-side
(ADR-0012's `maxFrameBytes` guard), surfacing only as a confirmation
timeout — and in production Cloudflare's ~1 MiB edge cap on inbound
WebSocket messages means the frame may never reach the DO at all. The
transport now guards **before sending**: a `mut`/`call` whose encoded size
exceeds the 1 MiB edge cap rejects immediately with
`MutationRejectedError` (code `"FRAME_TOO_LARGE"`), so the optimistic
overlay rolls back promptly. The server's silent drop stays as defense in
depth. Outbound gains a warn-only fixed 1 MiB threshold: a larger encoded
frame logs a `console.warn` with size and collection but is still sent
whole — column projection (#28a) remains the real fix for oversized
full-row re-sends. The limits are infrastructure facts (Cloudflare's edge
cap), not application preferences, so they are constants, not options.

- **Transport reconnect policy (ADR-0016; fixes #25, #26).** One option,
`reconnectDelay?: number | ((attempt, closeCode?, closeReason?) => number | null)`
Expand Down
126 changes: 126 additions & 0 deletions docs/adr/0018-oversize-frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# ADR-0018: Oversize frames — client pre-send guard is the rejection surface; outbound is warn-only

**Status**: Accepted
**Date**: 2026-07-22
**Issue**: [#28](https://github.com/grrowl/tanstack-durable-object-sync/issues/28) (part b; column projection, part a, remains open)

## Context

ADR-0012's `maxFrameBytes` guard drops an oversize inbound frame with a
server-side `console.error` and **no reply**. For a hostile frame that is the
right stance; for an honest client whose mutation happens to carry a large
value (a multi-MB TEXT column, say) it is the worst failure mode we have: the
`mut` vanishes, the client's `sendMut` waits out its full confirmation
timeout, and the optimistic overlay rolls back only via the generic timeout
error — untyped, uncoded, seconds late.

Outbound had no size awareness at all: `drainAndBroadcast` hydrates full rows
(`SELECT *`), so a small-field UPDATE on a row holding a large column re-sends
the whole row per tick per subscriber, silently.

The load-bearing asymmetry (verified in workerd; documented in Cloudflare's
WebSocket limits):

- **Inbound** (client → DO) WebSocket messages are capped at ~1 MiB **at the
edge** in production. An oversize client frame may never reach the DO at
all — workerd may refuse the send or close the socket before
`webSocketMessage` runs. A server-side rejection therefore *cannot* be the
primary surface: there may be no server-side anything.
- **Outbound** (DO → client) is not capped the same way; a >1 MiB frame is
delivered whole (pinned by `tests/frame-limits.test.ts`, snapshot and live
delta).

## Decisions

### D0: The limits are infrastructure facts, not application preferences

Cloudflare's ~1 MiB inbound edge cap is a **fact about the one infrastructure
this library supports** — and both wire endpoints ship in this package. Facts
don't get knobs. The thresholds below are therefore module-level **constants**
(`MAX_FRAME_BYTES` in the transport, `WARN_OUTBOUND_FRAME_BYTES` in the
mixin), both `1_048_576`. If Cloudflare changes the cap, the constants change
with an ADR note.

**Rejected alternative — configurable limits** (a `maxFrameBytes` transport
option and a `warnOutboundFrameBytes: number | null` protected tunable, in an
earlier revision of this branch): rejected on review. Since we control both
sides and support exactly one infra, a client-side knob could only be set
*below* the cap (pointless — the server and edge still enforce the fact) or
*above* it (a lie — the guard would wave through frames the edge then kills,
reintroducing exactly the silent-timeout failure this ADR removes). The
warn-threshold knob likewise had no honest setting other than the cap itself;
`null`-disabling it only hid the one production breadcrumb.

The overall shape: **inbound = enforced constant; outbound = deliberately
unenforced** (breaking a correct broadcast to save bandwidth would invert the
failure hierarchy), with the D3 warning as the only production breadcrumb.

### D1: Client pre-send guard — the reliable half

Before sending a `mut` or `call`, the transport encodes the frame once,
checks the encoded size against `MAX_FRAME_BYTES`, and on breach rejects
**locally and immediately** with the existing typed surface:
`MutationRejectedError` with code `"FRAME_TOO_LARGE"`. No round trip, no
timeout; the optimistic overlay rolls back promptly through TanStack's normal
rejected-mutation path. The encoded bytes are reused for the actual send, so
the guard costs no extra encode.

Size is measured on the encoded wire value: `byteLength` for the binary
codec, UTF-8 bytes (not UTF-16 code units) for the JSON debug codec's string
output — `.length` would undercount non-ASCII payloads and let them slip
past the guard only to die at the edge (codex review).

Only `mut`/`call` are guarded — they are the frames that carry client data
and have a typed rejection surface. `sub`/`fetch`/`unsub` frames are
structurally small in practice; a pathological `where` predicate could in
principle also exceed the cap (codex raised this), but that exposure predates
this ADR, carries no row data, and has no typed rejection surface to reuse —
a bounded-predicate rule would be its own decision. Explicitly out of scope
here.

### D2: Server drop stays a drop — with the reason on record

The ADR-0012 inbound guard is unchanged in behavior: oversize → drop +
`console.error`, no reply. A typed `rejected` would require the `txId`, and
recovering it means decoding the very payload the guard exists **not** to
decode — the bound is on memory/CPU, and a partial/prefix decode of
MessagePack would be a hand-rolled parser coupled to field order, for a path
production traffic mostly cannot reach (the edge cap drops it first). The
drop is now explicitly framed as defense in depth behind D1, and the code
comment says so.

### D3: Outbound is warn-only — observability, not enforcement

When an encoded outbound frame exceeds `WARN_OUTBOUND_FRAME_BYTES` (fixed
1 MiB, per D0), the DO logs a `console.warn` with the frame type, byte size,
the collection (resolved from the socket's subscription registry only on the
warn path — never a hot-path lookup), and a pointer at the real fix (column
projection, issue #28). The frame is **still sent whole**.

Why not split or drop: outbound has no edge cap, so delivery works; splitting
would need a reassembly protocol for a problem whose real fix is sending less
— column projection / changed-columns-only patches, which is issue #28 part
(a) and deliberately out of scope here. The warning is the operator's signal
that they are paying the full-row-hydration cost.

## Consequences

- An oversize client mutation now fails in milliseconds with
`MutationRejectedError` / `"FRAME_TOO_LARGE"` instead of a 5 s generic
timeout; optimistic state rolls back promptly. Pinned by
`tests/frame-limits.test.ts` (raw transport and collection-level rollback).
- No new public API surface: both thresholds are constants (D0), so there is
nothing to configure and nothing to misconfigure.
- Large outbound rows keep working exactly as before (whole, single frame) —
now with a warning naming the collection and pointing at column projection
(#28) when they cross the threshold.
- The server's silent inbound drop is unchanged and remains pinned by
`tests/wire-hardening.test.ts`; its comment now explains why it stays
silent. Its measure for **string** frames is still `message.length`
(UTF-16 code units) per ADR-0012 D2 — an undercount for non-ASCII text
frames, flagged here rather than silently changed; the wire default is
binary, where the measure is exact.
- A socket that refuses a `mut`/`call` send synchronously (workerd does this
for frames over its own cap) now cleans up the pending-receipt waiter and
its timeout before rejecting, instead of leaving a stale entry armed for
`timeoutMs` (codex review).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ explains the displacement.
| [0015](./0015-syncable-mixin.md) | `Syncable` mixin: the sync core as a mixin over any DO base | Accepted (reframes 0001 D13; Actor unsupported) |
| [0016](./0016-reconnect-policy.md) | Transport reconnect policy: injectable delay function, terminal 4xxx, jittered backoff | Accepted |
| [0017](./0017-blob-wire-normalization.md) | BLOB wire normalization: bare ArrayBuffer becomes Uint8Array at emission | Accepted |
| [0018](./0018-oversize-frames.md) | Oversize frames: client pre-send guard is the rejection surface; outbound is warn-only | Accepted |
44 changes: 41 additions & 3 deletions src/client/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
// The socket opener is injectable: the browser default is `new WebSocket(url)`;
// other runtimes (and tests) provide an already-connected socket.

import { createFrameCodec, type FrameCodec } from "../wire/frame-codec.ts"
import { createFrameCodec, type FrameCodec, type WireOut } from "../wire/frame-codec.ts"
import type { ClientFrame, RowOp, ServerFrame } from "../wire/frames.ts"

/** Minimal structural socket — satisfied by both browser WebSocket and a
Expand All @@ -35,6 +35,14 @@ export interface SubHandler {
onReset(): void
}

/** Cloudflare's inbound WebSocket edge cap, ~1 MiB (ADR-0018). An
* infrastructure FACT, not an application preference: both wire endpoints
* ship in this package and the only supported infra fixes the number, so it
* is a constant, not a knob — a knob could only lower it pointlessly or
* raise it into the edge cap's lie. If Cloudflare changes the cap, this
* constant changes with an ADR note. Mirrors the server's ADR-0012 default. */
const MAX_FRAME_BYTES = 1_048_576

export class MutationRejectedError extends Error {
constructor(
message: string,
Expand Down Expand Up @@ -405,20 +413,50 @@ export class WebSocketTransport<Api = unknown> {
}

private async sendAwaitingReceipt(frame: ClientFrame, txId: string): Promise<{ result?: unknown }> {
// Pre-send size guard (ADR-0018): the reliable half of oversize handling.
// Cloudflare's edge caps inbound WS messages at ~1 MiB, so an oversize
// frame may never reach the DO — waiting for a server rejection would just
// be the confirmation timeout. Reject here, typed and immediate, so the
// optimistic overlay rolls back promptly. Encode once; the bytes are
// reused for the actual send.
const encoded = this.codec.encode(frame)
// A string frame (the JSON debug codec) goes over the wire as UTF-8 —
// measure bytes, not UTF-16 code units, or non-ASCII payloads undercount
// and slip past the guard only to die at the edge cap (codex review).
const bytes = typeof encoded === "string" ? new TextEncoder().encode(encoded).byteLength : encoded.byteLength
if (bytes > MAX_FRAME_BYTES) {
throw new MutationRejectedError(
`frame too large (${bytes} bytes > the ${MAX_FRAME_BYTES}-byte inbound WebSocket edge cap)`,
"FRAME_TOO_LARGE",
)
}
await this.connect()
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingTx.delete(txId)
reject(new Error(`confirmation timeout: txId=${txId}`))
}, this.timeoutMs)
this.pendingTx.set(txId, { resolve, reject, timer })
this.sendFrame(frame)
// A socket may refuse a send synchronously (workerd does for frames over
// its own cap). Clean up the waiter/timer before rejecting, or the stale
// entry lingers with an armed timeout for timeoutMs (codex review).
try {
this.sendRaw(encoded)
} catch (e) {
clearTimeout(timer)
this.pendingTx.delete(txId)
reject(e instanceof Error ? e : new Error(String(e)))
}
})
}

private sendFrame(frame: ClientFrame): void {
this.sendRaw(this.codec.encode(frame))
}

private sendRaw(data: WireOut): void {
if (!this.ws) throw new Error("transport not connected")
this.ws.send(this.codec.encode(frame))
this.ws.send(data)
}

private onMessage(data: unknown): void {
Expand Down
36 changes: 34 additions & 2 deletions src/server/mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ import { SubscriptionRegistry, type Sub } from "./subscriptions.ts"
* this tag, so tddc never touches a host's sockets and vice versa (ADR-0015). */
export const SYNC_TAG = "_tddc"

/** Outbound frame-size warning threshold (ADR-0018): observability only, and
* deliberately NOT a knob. Inbound is enforced at the edge-cap constant;
* outbound is deliberately unenforced — breaking a correct broadcast to save
* bandwidth would invert the failure hierarchy — so this warn is the one
* production breadcrumb. The real fix for oversize full-row rebroadcasts is
* column projection (issue #28). */
const WARN_OUTBOUND_FRAME_BYTES = 1_048_576

/** Runtime options for the mixin. `configure()` in your constructor.
* Numeric tuning knobs stay protected overridable fields (see ADR-0015). */
export interface SyncableOptions<TUser = unknown> {
Expand Down Expand Up @@ -325,6 +333,14 @@ export function Syncable<Env = unknown, TUser = unknown>() {

// Reject oversize frames before decode (ADR-0012): mirrors the
// undecodable-frame stance — drop + log, no reply, no crash.
//
// No typed `rejected` here (ADR-0018): recovering the txId would mean
// decoding the very payload this guard exists NOT to decode (the bound
// is on memory/CPU, per ADR-0012). In production Cloudflare's edge caps
// inbound WS messages at ~1 MiB anyway, so an oversize frame usually
// never reaches this handler at all — the client transport's pre-send
// guard (same limit, MutationRejectedError "FRAME_TOO_LARGE") is the
// reliable rejection surface; this drop is defense in depth.
const byteLen = typeof message === "string" ? message.length : message.byteLength
if (byteLen > this.maxFrameBytes) {
console.error(`oversize frame dropped (${byteLen} bytes > maxFrameBytes ${this.maxFrameBytes})`)
Expand Down Expand Up @@ -868,9 +884,25 @@ export function Syncable<Env = unknown, TUser = unknown>() {
this.#send(ws, { t: "uptodate", seq })
}

/** Encode and send a server frame on one socket. */
/** Encode and send a server frame on one socket. Warns — and still sends
* whole — when the encoded frame exceeds `WARN_OUTBOUND_FRAME_BYTES`
* (ADR-0018): observability for hydrated full-row re-sends, whose real
* fix is column projection (issue #28). */
#send(ws: WebSocket, frame: ServerFrame): void {
ws.send(this.#codec.encode(frame))
const encoded = this.#codec.encode(frame)
const bytes = typeof encoded === "string" ? encoded.length : encoded.byteLength
if (bytes > WARN_OUTBOUND_FRAME_BYTES) {
// Resolve the sub's collection only on the warn path (linear scan
// over this socket's subs — never on the hot path).
const subId = (frame as { sub?: string }).sub
const collection = subId ? this.#subs.forWs(ws).find((s) => s.subId === subId)?.collection : undefined
const where = collection ? ` for collection '${collection}'` : subId ? ` for sub '${subId}'` : ""
console.warn(
`oversize outbound '${frame.t}' frame (${bytes} bytes > ${WARN_OUTBOUND_FRAME_BYTES})${where} — ` +
`full-row rebroadcast; column projection (issue #28) is the real fix`,
)
}
ws.send(encoded)
}

/** The attachment bound at upgrade, surviving hibernation. */
Expand Down
75 changes: 75 additions & 0 deletions tests/error-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { createCollection } from "@tanstack/db"
import { env, runInDurableObject, SELF } from "cloudflare:test"
import { describe, expect, it } from "vitest"
import { doCollectionOptions } from "../src/client/do-collection.ts"
import { WebSocketTransport, type WebSocketLike } from "../src/client/transport.ts"
import { createFrameCodec } from "../src/wire/frame-codec.ts"
import type { ClientFrame, ServerFrame } from "../src/wire/frames.ts"
import type { TestApi } from "./test-worker.ts"

// WHY: these tests pin three load-bearing invariants in the server's error paths:
// 1. Atomicity — a failed mutation leaves no partial state; nothing is applied
Expand Down Expand Up @@ -204,6 +208,77 @@ describe("mutation error paths (atomicity, fail-loud, exactly-once)", () => {
})
})

describe("no mutation handler, at client altitude", () => {
// WHY: the wire-level sibling above ("unknown mutation collection →
// rejected") pins that the server answers /no mutation handler/ with a
// `rejected` frame. This pins the CLIENT altitude of the SAME server path:
// the rejection surfaces through a real @tanstack/db collection as a
// MutationRejectedError and the optimistic overlay rolls back promptly —
// a regression in either half (transport rejection plumbing, TanStack
// rollback wiring) ships silently past the wire-level pin alone.
//
// `transformed` declares ONLY an insert mutation — update/delete are the
// "no mutations block" case per-op. A collection with NO mutations at all
// takes the identical code path (mutations.get returns undefined).
it("client update on an op with no handler: MutationRejectedError + optimistic rollback", async () => {
const room = "fl-readonly"
const t = new WebSocketTransport<TestApi>({
url: `https://example.com/sync/${room}`,
open: async () => {
const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } })
const ws = res.webSocket
if (!ws) throw new Error("no webSocket")
ws.accept()
return ws as unknown as WebSocketLike
},
})
await t.connect()

const coll = createCollection(
doCollectionOptions({
transport: t,
table: "transformed",
getKey: (r) => r.id,
}),
)
await coll.preload()

// Seed via the existing insert mutation (allowed).
await coll.insert({ id: "r1", body: "orig" }).isPersisted.promise
expect(coll.get("r1")).toMatchObject({ body: "orig" })

// update has NO handler on `transformed`.
const tx = coll.update("r1", (d) => {
d.body = "hacked"
})
// Optimistic overlay applies immediately...
expect(coll.get("r1")).toMatchObject({ body: "hacked" })

let err: unknown
try {
await tx.isPersisted.promise
} catch (e) {
err = e
}
expect(err).toBeDefined()
// TanStack may wrap the mutationFn error; the root cause is the transport's
// MutationRejectedError carrying the server's message.
const msg =
err instanceof Error ? `${err.message} ${String((err as { cause?: unknown }).cause ?? "")}` : String(err)
expect(msg).toMatch(/no mutation handler for 'transformed:update'/)

// Optimistic overlay rolled back to the confirmed row.
expect(coll.get("r1")).toMatchObject({ body: "orig" })

// Server row untouched.
const rows = await runInDurableObject(env.SYNC_DO.get(env.SYNC_DO.idFromName(room)), (_i, s) =>
Array.from(s.storage.sql.exec("SELECT body FROM transformed WHERE id='r1'")),
)
expect((rows[0] as { body: string }).body).toBe("orig")
t.close()
})
})

describe("command error paths", () => {
it("command execute throws → rejected with generic message, detail not leaked", async () => {
const ws = await openWs("/sync/err-cmd-boom")
Expand Down
Loading