Add a Syncable() mixin so sync composes with Agent-based Durable Objects#22
Merged
Conversation
…mixin
Convert the sync machinery into a curried mixin factory `Syncable<Env,TUser>()(Base)`
that applies over any DurableObject subclass, so one DO can host both its
framework's WebSocket surface (Agent, Think, bare DO) and tddc's sync protocol.
`SyncDurableObject` becomes the trivial application over `DurableObject`, with its
legacy protected surface (`sql`, `registerSync`, `runSyncedWrite`, overridable
`parseAttachment`, `registry`, `drainAndBroadcast`) re-exposed as thin aliases —
zero breaking change, all existing imports and the 179-test suite unchanged.
Cohosting safety is tddc-side and by construction (ADR-0015):
- Reserved hibernation tag SYNC_TAG on every sync socket; wake-time restore uses
getWebSockets(SYNC_TAG) so the broadcaster never touches host sockets.
- Plain socket attachment (no partyserver `__pk`), the independent discriminator
that keeps a `__pk`-filtering host blind to sync sockets.
- Dedicated fetch path (default "/_sync"); non-matching upgrades and WS events on
untagged sockets delegate to `super`.
- The `sql` getter is DELETED from the mixin (it shadowed the host's `sql`
tagged-template method); internals use `this.ctx.storage.sql`.
- `setWebSocketAutoResponse("ping","pong")` and `PRAGMA case_sensitive_like = ON`
are base-dependent opt-ins: ON when Base === DurableObject (0.4.0 parity), OFF
over any other host.
Everything loose collapses behind a single `this.sync` facade to shrink the
name-collision surface with an arbitrary host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a partyserver-like fake host base (owns `__pk` sockets, filters foreign sockets in every hibernation handler, exposes a `sql` tagged template, upgrades on `/_host`) and the mixin applied over it, plus 8 cases pinning the cohosting contract (ADR-0015): sync/host socket coexistence, broadcast isolation, fetch path split with super.fetch fallthrough, foreign-message delegation to super, no `sql` shadow, trigger safety on a host-owned table with reaper GLOB safety, bare- DO 0.4.0 defaults, and base-dependent auto-response/pragma opt-in. Also finalize the mixin typing so declaration emit succeeds: the factory returns a single nameable construct signature (InstanceType<TBase> & SyncMixin) instead of an anonymous class (TS4094 on inherited protected ctx/env), and SyncDurableObject re-declares the protected tuning knobs (ambient) so existing `protected override readonly tickMs = …` subclasses keep compiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review (codex gpt-5.5) surfaced three edges:
- Legacy untagged sockets. A socket accepted by a pre-mixin 0.4.0 build carried
no tag; after the upgrade it could wake and be classified foreign, silently
dropping a pre-existing client's frames. A bare DurableObject shares its sockets
with no host, so it now owns EVERY socket — restore uses getWebSockets() (all)
and the ownership check returns true unconditionally. Tag-filtering stays for a
non-DO base, where a real host coexists.
- configure() was a dead toggle for `false`. The auto-response and pragma side
effects only applied on `true`, so configure({ autoResponse: false }) /
({ caseSensitiveLike: false }) could not undo the bare-DO defaults. Both now
apply the off-path (clear the auto-response pair; PRAGMA … = OFF).
- Reserved `__pk` in claims. A parseAttachment claim carrying partyserver's
reserved `__pk` key would make a host mis-claim the sync socket. The tag keeps
tddc's side correct regardless; #acceptSyncSocket now logs a loud error over a
non-DO base, and the README documents the reserved key.
Adds host-matrix cases pinning the legacy-untagged migration and the real
configure toggle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DME + CHANGELOG ADR-0015 records the decision to ship the server as a mixin factory with SyncDurableObject as its trivial application: the cohosting proof (partyserver __pk filtering + SYNC_TAG, two independent discriminators), the mandatory sql getter removal with shadowing evidence, the base-dependent auto-response/pragma defaults with the open-question grep findings (no host sends a literal "ping", no host LIKE relies on case-insensitivity, no host defines a `sync` member), the legacy-untagged-socket migration, the __pk claims constraint, and the Actor unsupported verdict with the Sockets evidence. Annotates ADR-0001 (D13 base class reframed as a mixin), 0006 (runSyncedWrite is also the host-tool write path), 0007 (author-driven registerSync composes with host constructors), 0008 (the GLOB trigger namespace is the collision-safety proof). README gains a "Cohosting" section (recipe, the two DO-global caveats, the never-register-a-host-table rule, Actor unsupported); CHANGELOG documents the mixin and the zero-change SyncDurableObject. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…et reads compileSubsetQuery emitted a bare `SELECT * FROM tbl` with no ORDER BY whenever the client sent no orderBy, leaving row order as an accident of SQLite's query plan: a WHERE touching the pk can make the planner prefer the pk's autoindex over a rowid scan, so same-millisecond writes arrived out of insertion order over the wire (field-verified against the cold eager-subscribe snapshot path). Default to ORDER BY rowid when no orderBy is given. rowid is always present (assertSyncCompatible/D9 forbids an INTEGER PRIMARY KEY pk that would alias it) and matches insertion order among currently-live rows, since a rowid table assigns each insert 1 + the current max regardless of intervening deletes. tests/subset-query.test.ts adds a regression that reproduces the bug against unfixed HEAD (WHERE on the pk drives SQLite to the autoindex, returning pk-sorted rows instead of insertion order); sql-compiler.test.ts pins the new default ORDER BY clause at the unit level. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… guarantee Two verifier-flagged gaps: (a) Syncable() must be applied exactly once over the outermost DO base — stacking it over another Syncable() application is a real tsc error, and forcing it with a cast still leaves the inner layer inert (fetch/webSocketMessage/sync are per-class overrides, so the outer layer's always shadow the inner's). (b) records the cold-snapshot/fetch ORDER BY rowid guarantee just shipped, and why rowid is the honest ordering key (ADR-0007 D9's TEXT-pk requirement keeps it unaliased). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
SyncDurableObject is a base class, and a JavaScript class can only have one base. A Durable Object that already extends another framework's base, e.g. the Agents SDK's Agent, cannot also be a sync scope. This PR turns the sync machinery into a mixin, so a class can extend Syncable()(Agent) and host both protocols on one Durable Object.
What changed
Verification
Known limits
@cloudflare/actors cannot host the mixin, because its Sockets helper adopts every hibernated socket at construction and closes sockets it does not recognize. ADR-0015 records this as unsupported.
🤖 Generated with Claude Code