Skip to content

Add a Syncable() mixin so sync composes with Agent-based Durable Objects#22

Merged
grrowl merged 6 commits into
mainfrom
feat/syncable-mixin
Jul 2, 2026
Merged

Add a Syncable() mixin so sync composes with Agent-based Durable Objects#22
grrowl merged 6 commits into
mainfrom
feat/syncable-mixin

Conversation

@grrowl

@grrowl grrowl commented Jul 2, 2026

Copy link
Copy Markdown
Owner

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

  • src/server/mixin.ts holds the new Syncable(Base) factory, exported from the root and from ./server/mixin. SyncDurableObject is now the mixin applied to DurableObject, so existing imports and behavior do not change.
  • Sync sockets are accepted with a reserved hibernation tag. The message, close, and error handlers check the tag and hand sockets they do not own to the parent class. Sync upgrades enter on a dedicated /_sync fetch path, and every other request falls through to the parent's fetch.
  • The sql getter is deleted because it shadowed the sql tagged-template method on partyserver's Server and on Agent. The protected members now live on a this.sync facade.
  • setWebSocketAutoResponse and the case_sensitive_like pragma are opt-in when the base is not DurableObject itself, because both settings apply to the whole Durable Object.
  • Cold snapshots and paginated fetch reads now compile with ORDER BY rowid when the client sends no orderBy. Before this change the compiled SQL had no ORDER BY, and SQLite's planner could return rows out of insertion order. A consuming app hit this in the field, and the new regression test reproduces the failure on the old code. Rows seeded z, a, m came back a, m, z once the WHERE clause touched the primary key.
  • ADR-0015 records the mixin decision, the snapshot ordering guarantee, and a note that Syncable() must not be applied twice.

Verification

  • The full suite passes with 191 tests. No pre-existing test file was modified. The additions are a 10-case host-matrix suite over a fake partyserver-like base and 2 ordering tests, one of which was confirmed to fail against the unfixed code.
  • A smoke test ran both protocols on one class extending Syncable()(Agent) against the real agents 0.17.3 package. The sync client received CDC rows, the agent client received setState broadcasts, the sync socket did not receive that broadcast, and Agent's sql method still worked. The agents package was a devDependency for the smoke only and was removed afterward.
  • Grep checks across agents 0.17.3, partyserver 0.5.8, and @cloudflare/think 0.12.1 found no literal "ping" keepalive frame, no LIKE query that needs case-insensitive behavior, and no existing member named sync.

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

grrowl and others added 6 commits July 2, 2026 21:31
…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>
@grrowl grrowl merged commit 09fe62b into main Jul 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant