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
38 changes: 30 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ While pre-1.0, the public API may change between 0.x releases.

## [Unreleased]

### Changed

- **Rejection reasons are surfaced uniformly for mutations and commands (revises
ADR-0012 D3).** An `authorize` throw or a schema validation failure now reaches
the client with its reason (validation failures carry a `VALIDATION` code);
only `execute` errors stay sanitized. Previously a command's `authorize` error
was sanitized like its `execute`, unlike a mutation's.

### Added

- **`Syncable(Base)` mixin — cohost sync on any Durable Object base (ADR-0015).**
The sync machinery is now a curried mixin factory,
`Syncable<Env, TUser>()(Base)`, so one DO can be both its framework's host (the
Agents SDK `Agent`, `@cloudflare/think`'s `Think`, a bare `DurableObject`) and a
tddc sync source — no dedicated sync DO, no mirror write. Exposed from the root
and a `./server/mixin` subpath. Sync sockets carry a reserved tag and a plain
attachment and claim only the `/_sync` path; all other traffic delegates to the
host base, so the two protocols never cross (proof: partyserver's `__pk`
filtering). `Actor` (`@cloudflare/actors`) is documented as unsupported because
its `Sockets` helper adopts foreign sockets on wake. See the README "Cohosting"
section.

- **Optional Standard Schema validation (ADR-0014).** A collection's
`insert.schema` (the row schema, which also infers the collection's Row) and
`update.schema` (a partial patch schema), and a command's schema, are checked
Expand All @@ -27,6 +31,24 @@ While pre-1.0, the public API may change between 0.x releases.
transforms, defaults, or coercion. See
`recipes/zod-standard-schema-collections.md`.

### Changed

- **`SyncDurableObject` is now `Syncable()(DurableObject)`** — zero API change.
All existing `extends SyncDurableObject<Env, Claims>` code, including
`this.sql`, `this.registerSync`, `this.runSyncedWrite`, and an overridable
`parseAttachment`, keeps compiling and behaving identically to 0.4.0 (the two
DO-global side effects — `ping/pong` auto-response and `PRAGMA
case_sensitive_like = ON` — stay ON for this base; they default OFF over any
other base, opt in with `this.sync.configure`). The internal `sql` getter was
removed from the mixin because it shadowed the host's `sql` tagged-template
method; reach `this.ctx.storage.sql` directly on a non-`DurableObject` base.

- **Rejection reasons are surfaced uniformly for mutations and commands (revises
ADR-0012 D3).** An `authorize` throw or a schema validation failure now reaches
the client with its reason (validation failures carry a `VALIDATION` code);
only `execute` errors stay sanitized. Previously a command's `authorize` error
was sanitized like its `execute`, unlike a mutation's.

## [0.4.0] — 2026-07-01

### Changed
Expand Down
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,78 @@ Task-oriented guides in [`recipes/`](./recipes):

---

## Cohosting: `Syncable` over a framework base

`SyncDurableObject` is the trivial application of a mixin, `Syncable(Base)`, that
adds the sync machinery to **any** Durable Object subclass. Use it when a DO
already extends a framework base — the Cloudflare Agents SDK `Agent`,
`@cloudflare/think`'s `Think` — and you want that same DO to also be a sync
source, instead of standing up a second DO and mirror-writing to it (ADR-0015).

```ts
import { Syncable } from "tanstack-do-db-collection" // or ".../server/mixin"

// Curried: pin Env and your claims type, then apply over the runtime base.
class FeedAgent extends Syncable<Env, Claims>()(Agent<Env, State>) {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env) // host constructor first
// Auth hook for the sync upgrade (same contract as parseAttachment):
this.sync.configure({ parseAttachment: (req) => readClaims(req) })
ctx.blockConcurrencyWhile(async () => {
migrate(ctx.storage.sql) // you create the tables…
this.sync.registerSync(feedSchema) // …then register (ADR-0007)
})
}
}
```

The sync API lives behind one facade, `this.sync` (`registerSync`,
`runSyncedWrite`, `parseAttachment`, `configure`), so the only names the mixin
adds to your class are `sync` and the four WebSocket/`fetch` handlers. tddc's
sockets carry a reserved tag and a plain attachment, and it claims only the
`/_sync` path (configurable) — everything else is delegated to your host base, so
the two protocols never cross. No framework is added to tddc's dependency graph;
you supply `Base`.

**Reach `this.ctx.storage.sql`, not `this.sql`, on a mixed base.** The mixin does
**not** define a `sql` member, because both partyserver and agents define `sql`
as a tagged-template method and a getter would shadow it. (`SyncDurableObject`
still has `this.sql` — a bare `DurableObject` has no `sql` to shadow.)

**Your `parseAttachment` claims must not use the key `__pk`.** partyserver marks
its own sockets with a `__pk` attachment key; a sync claim object carrying `__pk`
would make a partyserver-like host mis-claim the sync socket. The reserved tag
keeps tddc's own side correct regardless, and the mixin logs an error if it sees
`__pk` in a sync attachment — but keep it out of your claims.

> [!IMPORTANT]
> **Two DO-global side effects default OFF over a non-`DurableObject` base**, and
> ON for plain `SyncDurableObject` (0.4.0 behavior). Opt in with `configure`:
> - `autoResponse` — `setWebSocketAutoResponse("ping","pong")` is DO-wide and
> would answer a literal `"ping"` frame from *your host's* client before the
> host sees it.
> - `caseSensitiveLike` — `PRAGMA case_sensitive_like = ON` is connection-wide and
> changes your host's own `LIKE` queries. (tddc needs it for filtered-sub
> parity — ADR-0013.)
>
> ```ts
> this.sync.configure({ autoResponse: true, caseSensitiveLike: true })
> ```

> [!WARNING]
> **Never register a host-owned table as a synced collection.** tddc installs CDC
> triggers only on the tables you register, so a host's own tables
> (`cf_agents_*`, Think's `assistant_*`) stay untouched by default. But nothing
> stops you from *registering* one by mistake — do not, or every host write to it
> emits change rows to your clients.

**`@cloudflare/actors`' `Actor` is not supported.** Its `Sockets` helper adopts
every hibernated socket on wake (including tddc's), broadcasts to them, and closes
sockets it does not own — a host defect the mixin cannot work around without
changing the Actors package. Use `Agent`, `Think`, or a plain `DurableObject`.

---

## Non-goals

- **Multi-DO transactions.** A transaction touches collections in one DO.
Expand Down
6 changes: 5 additions & 1 deletion docs/adr/0001-sync-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

**Status:** Accepted, **amended by [ADR-0002](./0002-adversarial-review-corrections.md)**
(ordering barrier, on-demand shaping, retention/liveness, before-image dropped).
Read 0002 alongside this document.
Read 0002 alongside this document. **D13's "base class" is reframed as a mixin by
[ADR-0015](./0015-syncable-mixin.md)**: the sync core is now `Syncable(Base)` and
`SyncDurableObject` is its trivial application over `DurableObject` — same
hibernation/`acceptWebSocket`/`serializeAttachment` mechanics, now composable over
any DO base.

## Context

Expand Down
4 changes: 4 additions & 0 deletions docs/adr/0006-server-originated-writes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
model with a third write origin. The "caller ensures init" caveat below is
retired by [ADR-0007](./0007-author-owned-schema-register-sync.md) (schema +
triggers now exist at construction via `registerSync`).
[ADR-0015](./0015-syncable-mixin.md) leaves `runSyncedWrite` unchanged and makes
it also the write path for a host tool body on a mixed base
(`this.sync.runSyncedWrite`), so a committed insert drains the CDC log and
broadcasts in the same step regardless of the DO's framework base.

## Context

Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0007-author-owned-schema-register-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
moves the [ADR-0001](./0001-sync-architecture.md) D9 enforcement point, and
retires [ADR-0006](./0006-server-originated-writes.md)'s init caveat. Hard
breaking change to the DO authoring API (pre-1.0).
[ADR-0015](./0015-syncable-mixin.md) leans on this decision: because
`registerSync` is author-driven (called in the constructor's
`blockConcurrencyWhile` after the tables exist), the mixin needs no
base-constructor magic and composes cleanly with a host base's own constructor —
the author creates tables, then calls `this.sync.registerSync`, in that order.

## Context

Expand Down
4 changes: 4 additions & 0 deletions docs/adr/0008-orphaned-cdc-triggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
**Status:** Accepted — implemented. Records a limitation of
[ADR-0007](./0007-author-owned-schema-register-sync.md)'s `registerSync` and the
fix now shipped: Option 1, reaping in `registerSync`.
[ADR-0015](./0015-syncable-mixin.md) cites this GLOB `_sync_changes_*` namespace
as its trigger-collision-safety proof: on a host that owns unregistered tables
(`cf_agents_*`, `assistant_*`), the reaper's literal-`_` GLOB can never drop a
host trigger, and unregistered tables get no capture triggers at all.

## Context

Expand Down
Loading