From 9b35e8174d487544514c6159681135a19563367d Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Mon, 29 Jun 2026 17:11:05 +1000 Subject: [PATCH 01/10] feat(registry)!: object-schema authoring API + typed client (ADR-0014) Replace the `new SyncRegistry().defineCollection()/defineMutation()/ defineCommand()` builder with `defineSync()` -> `{ collection, command, schema }`. Mutations are a closed insert/update/delete trio co-located on the collection (mirrors @tanstack/db's onInsert/onUpdate/ onDelete; a custom mutation type is structurally unrepresentable), superseding ADR-0001 D11 and absorbing ADR-0010's row manifest. A collection's Row comes from an explicit `collection(...)` generic, or is inferred from the row schema on its insert mutation (`collection({ mutations: { insert: { schema } } })`). Commands are typed end to end: `type Api = typeof schema` drives a typed `transport.call.(args)` proxy and a typed `sendCall(name, args)` (txId via crypto.randomUUID), and `doCollectionOptions` infers the row type from the schema. The optional Standard Schema slots (insert.schema, update.schema, command schema) type cols/args; runtime validation against them lands in a stacked follow-up PR. Runtime dispatch/wire/CDC semantics are unchanged: `registerSync` compiles the schema value into the same dispatch tables, so the suite passes as-is (171/171). Breaking, no compat shim (pre-1.0). BREAKING CHANGE: `SyncRegistry` and its defineCollection/defineMutation/ defineCommand builder are removed; author DOs with `defineSync`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- CHANGELOG.md | 26 +- README.md | 115 +++++--- docs/adr/0014-object-sync-schema.md | 191 ++++++++++++ docs/adr/README.md | 5 +- src/client/do-collection.ts | 47 ++- src/client/index.ts | 7 +- src/client/transport.ts | 42 ++- src/server/changes.ts | 4 +- src/server/index.ts | 22 +- src/server/registry.ts | 439 +++++++++++++++++++++------- src/server/sync-do.ts | 17 +- tests/registry-types.ts | 116 ++++---- tests/registry.test.ts | 32 +- tests/sync-write.test.ts | 48 +++ tests/test-worker.ts | 155 +++++----- tests/transport.test.ts | 18 +- 16 files changed, 968 insertions(+), 316 deletions(-) create mode 100644 docs/adr/0014-object-sync-schema.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8f8c0..52a88c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,31 @@ While pre-1.0, the public API may change between 0.x releases. ## [Unreleased] -_Nothing yet._ +### Changed + +- **Authoring is now an object schema, not a builder (ADR-0014).** + `new SyncRegistry().defineCollection().defineMutation().defineCommand()` is + replaced by `defineSync()`, which binds identity/env once and + returns `{ collection, command, schema }`. Mutations are a **closed + insert/update/delete trio** co-located on the collection (mirroring + `@tanstack/db`'s `onInsert/onUpdate/onDelete` — a custom mutation type is now + structurally unrepresentable), superseding ADR-0001 D11 and absorbing + ADR-0010's row manifest: the row type lives on the collection + (`sync.collection({ pk })`) instead of a third `SyncRegistry` + generic. The DO registers the schema value with `this.registerSync(schema)`. + Breaking, no compat shim. + +### Added + +- **Typed commands, end to end (ADR-0014).** `export type Api = typeof schema` + is the whole client contract: `new WebSocketTransport()` exposes a typed + `transport.call.(args)` proxy plus a typed low-level + `sendCall(name, args)` (txId generated internally via `crypto.randomUUID()`), + and `doCollectionOptions({ … })` infers the row type from the + schema — one source of truth across server and client. +- **`examples/multi-do`** — a two-Durable-Object example: one transport per DO, a + React `SyncProvider`/`useSync` keyed by DO so command namespaces never + collide, and a client-side cross-DO feed. ## [0.3.3] — 2026-06-13 diff --git a/README.md b/README.md index a952e40..636aa2f 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,74 @@ the milestone sequence. ### 1. Define your Durable Object ```ts -import { SyncRegistry, SyncDurableObject } from "tanstack-do-db-collection" +import { defineSync, SyncDurableObject } from "tanstack-do-db-collection" interface Claims { userId: string } +interface Env { /* your bindings */ } interface Message { id: string; author: string; content: string; created_at: number } +// defineSync binds identity (Claims) and binding-env (Env) once and returns +// three co-located helpers. They flow `user`/`env` into every handler ctx. +const sync = defineSync() + +// The schema VALUE is both the DO registration and the client contract. The +// collection KEY ("messages") is the DB table name. `pk` must be a real column +// of Row — the sole TEXT, client-supplied key (ADR-0007). +export const chatSchema = sync.schema({ + collections: { + messages: sync.collection({ + pk: "id", + // The closed mutation trio { insert?; update?; delete? } — a 4th key is a + // type error. op.cols is typed per op: full Row on insert, Partial on + // update, absent on delete. + mutations: { + insert: { + // authorize runs BEFORE the tx (async ok); throw to deny. + // op.cols is typed Message here — no cast. + authorize: ({ user, op }) => { + if (op.cols.author !== user.userId) { + throw new Error("author mismatch") + } + }, + // execute runs INSIDE transactionSync — synchronous only. + execute: ({ op, sql }) => { + const m = op.cols // Message + sql.exec( + "INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)", + m.id, m.author, m.content, m.created_at, + ) + }, + // afterCommit (optional): fire-and-forget AFTER the commit + receipt — + // the home for external side effects execute can't do (delete an R2 + // object, enqueue a job). Receives `env`; owns its own idempotency. + // afterCommit: async ({ op, env }) => { await env.BUCKET.delete(op.key) }, + }, + delete: { + execute: ({ op, sql }) => { + // delete carries op.key only — no op.cols. + sql.exec("DELETE FROM messages WHERE id = ?", op.key) + }, + }, + }, + }), + }, + // Commands are the escape hatch for writes that aren't a single typed row op. + // Their own SQL still flows through the CDC triggers, and they can return a + // result. Type-only Args is curried (call the factory twice); Result is + // inferred from the return. If you have commands you MUST declare them inline + // here so Args/Result inference flows into the Api type. + commands: { + clearRoom: sync.command()(({ sql }) => { + const before = Array.from(sql.exec("SELECT count(*) AS c FROM messages"))[0]!.c as number + sql.exec("DELETE FROM messages") + return { deleted: before } + }), + }, +}) + +// Export the schema type as the client contract. +export type Api = typeof chatSchema + export class SessionDO extends SyncDurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) @@ -101,35 +164,9 @@ export class SessionDO extends SyncDurableObject { created_at INTEGER NOT NULL )`) - this.registerSync( - // The third generic is the collection manifest: table → row type. It - // types `pk` (must be a column) and every handler's `op` — no casts. - new SyncRegistry() - .defineCollection({ table: "messages", pk: "id" }) - .defineMutation({ - collection: "messages", - type: "insert", - // authorize runs BEFORE the tx (async ok); throw to deny. - // op.cols is typed Message here — no cast. - authorize: ({ user, op }) => { - if (op.cols.author !== user.userId) { - throw new Error("author mismatch") - } - }, - // execute runs INSIDE transactionSync — synchronous only. - execute: ({ op, sql }) => { - const m = op.cols // Message - sql.exec( - "INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)", - m.id, m.author, m.content, m.created_at, - ) - }, - // afterCommit (optional): fire-and-forget AFTER the commit + receipt — - // the home for external side effects execute can't do (delete an R2 - // object, enqueue a job). Receives `env`; owns its own idempotency. - // afterCommit: async ({ op, env }) => { await env.BUCKET.delete(op.key) }, - }), - ) + // registerSync takes the schema VALUE — it compiles it, validates pk + // affinity, and wires the CDC triggers. + this.registerSync(chatSchema) }) } @@ -187,10 +224,14 @@ import { createCollection } from "@tanstack/db" import { useLiveQuery } from "@tanstack/react-db" import { doCollectionOptions, WebSocketTransport } from "tanstack-do-db-collection/client" import { ulid } from "ulid" +import type { Api } from "./session-do" // TYPE-ONLY — nothing server-side is bundled + +const transport = new WebSocketTransport({ url: `wss://${host}/sync/${sessionId}` }) -const transport = new WebSocketTransport({ url: `wss://${host}/sync/${sessionId}` }) +// Api-driven: the row type is inferred from the schema Api + table name, so +// there's no runtime schema value and no explicit Row generic. `m` is Message. const messages = createCollection( - doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), + doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), ) function ChatRoom({ userId }: { userId: string }) { @@ -198,13 +239,19 @@ function ChatRoom({ userId }: { userId: string }) { const send = (content: string) => // Optimistic; resolves once the server confirms on the single stream. messages.insert({ id: ulid(), author: userId, content, created_at: Date.now() }) - return + // Commands run over the transport (not the collection). `call` is a typed + // Proxy — the name autocompletes and the result is checked against the Api. + const clear = () => transport.call.clearRoom() // Promise<{ deleted: number }> + return } ``` One `WebSocketTransport` per DO is shared by every collection on that DO (multiplexed over the single socket). Pass `where` to -`doCollectionOptions` to sync only a matching subset. +`doCollectionOptions` to sync only a matching subset. Commands go through the +same socket: `transport.call.(args)` (typed sugar) or the low-level +`transport.sendCall("clearRoom", undefined)` — both mint the txId for you and +resolve with the command's result on `committed`. --- diff --git a/docs/adr/0014-object-sync-schema.md b/docs/adr/0014-object-sync-schema.md new file mode 100644 index 0000000..0e76087 --- /dev/null +++ b/docs/adr/0014-object-sync-schema.md @@ -0,0 +1,191 @@ +# 0014 — `defineSync`: one schema value, mutations on the collection, commands on the connection + +**Status:** Accepted. Supersedes [ADR-0001](./0001-sync-architecture.md) D11's +`defineMutation`/`defineCommand` builder and the [ADR-0007](./0007-author-owned-schema-register-sync.md) +`new SyncRegistry().defineCollection(…)` chain; closes the ADR-0010 manifest and +its "typed command args" out-of-scope follow-up. Hard breaking change to the DO +authoring API and the typed client surface (pre-1.0, clean break). + +## Context + +Three threads converged. ADR-0007 inverted control so the author owns migration +and `registerSync`es a `Registry`. ADR-0010 wanted `op.cols` typed per op, and +landed on a constructor **manifest** (`new SyncRegistry()`) +because TypeScript has no partial type-argument inference (microsoft/TypeScript#26242): +the row type had no runtime witness in `{ table, pk }`, so it had to be an explicit +annotation, and an explicit `` killed inference of every other slot. ADR-0010 +explicitly deferred typed command `args`, and noted the manifest "sits apart from the +`.defineCollection` calls." + +That manifest is the seam that hurt. The row type lives in a generic on the +constructor; the `pk` and the mutations live in chained `.defineCollection` / +`.defineMutation` calls somewhere below; the client recovers nothing — it imports +no shared type, so `op.cols` on the wire and `transport.sendCall(frame)` on the +client are both `unknown`/hand-built. Authoring is split across three sites +(manifest, collection, mutation) and the end-to-end type story stops at the DO. + +We also had two genuinely different shapes wearing one builder. A **mutation** +mirrors a row write — TanStack DB already names exactly `onInsert`/`onUpdate`/`onDelete`, +and a synced write *is* one of those three. A **command** is an RPC: free-form +args, a return value, its own atomicity, no collection. ADR-0010 lumped them as +sibling `defineX` methods; ADR-0012 D3 had already found they diverge at runtime +(a command's `authorize` throw is sanitized, a mutation's stays user-facing). + +## Decision + +One factory, `defineSync()`, binds identity and binding-env **once** +and returns three co-located helpers `{ collection, command, schema }`. The output +of `sync.schema({ collections, commands })` is a single **value** that is *both* +the DO registration and the client contract: `this.registerSync(chatSchema)` on +the server, `export type Api = typeof chatSchema` for the client. + +```ts +const sync = defineSync() +export const chatSchema = sync.schema({ + collections: { + messages: sync.collection({ // KEY "messages" === table name + pk: "id", // keyof Row & string (ADR-0007 D9) + mutations: { // CLOSED trio + insert: { authorize, execute, afterCommit }, // op.cols: Message + update: { execute }, // op.cols: Partial + delete: { execute }, // op.key only, no cols + }, + }), + }, + commands: { // OPEN, named + clearRoom: sync.command<{ before?: number }>()(({ args, sql }) => ({ deleted: 0 })), + purge: sync.command(zArgs, ({ args }) => ({ ok: args.hard })), + ping: sync.command()(() => ({ pong: true as const })), + }, +}) +export type Api = typeof chatSchema +``` + +### D1: Mutations are the closed `insert`/`update`/`delete` trio, co-located on the collection + +Mutations move from sibling `.defineMutation({ collection, type, … })` calls onto +a `mutations` object inside the collection, keyed by op type. This mirrors TanStack +DB's `onInsert`/`onUpdate`/`onDelete` — the surface authors already know, and the +surface a synced write maps onto one-for-one. The trio is **closed**: a 4th key +(e.g. `archive`) is an excess-property type error. + +It is closed *because it has to be*. The op `type` bridges TanStack's `OperationType`, +and the three op shapes are structurally distinct — `insert` carries `cols: Row` +(the full row, ADR-0001 D19), `update` carries `cols: Partial` (top-level +patch, ADR-0002 C6), `delete` carries no `cols`, just `key`. A custom op type has +no `OperationType` to bridge and no defined `cols` shape, so it is structurally +unrepresentable, not merely disallowed. Co-locating the trio is what lets `op` be +typed per kind from one row annotation, replacing ADR-0010's manifest+chain split. +Named, open-ended writes are not a missing mutation kind — they are commands (D2). + +This **supersedes ADR-0001 D11's `defineMutation` builder** and absorbs ADR-0010: +the constructor manifest is gone, the row type lives on its own collection, and +`op.cols`/`op.key` are still precise per op — now from a single co-located site. + +### D2: Commands are the open, named RPC escape hatch on the connection, not a collection + +A command is keyed by a free name under `commands`, takes free-form `args`, and +returns a `Result`. It is **not** a collection operation: it lives on the +connection (the transport / DO), can write rows of *any* collection — those writes +broadcast through the same CDC triggers (ADR-0001), so subscribers see them like +any mutation — **and** returns a value to the caller. Crucially a command **owns +its own atomicity**: its `execute` runs *outside* `transactionSync` (so it may be +`async`), whereas a mutation's `execute` is synchronous inside the transaction. +This is the open counterpart to the closed trio: anything that isn't one-row +insert/update/delete (multi-row, cross-collection, compute-and-return, no write at +all) is a command. + +The two shapes stay distinct at runtime exactly as ADR-0012 D3 found: a command's +`authorize` throw is logged and surfaced as a generic rejection; a mutation's +`authorize` throw is a distinct user-facing message. Keeping them separate +helpers, rather than ADR-0010's sibling `defineX` methods, makes that divergence +structural instead of incidental. + +### D3: Row co-located on the collection; an optional Standard Schema slot gives runtime validation + inference + +The row type lives on the collection two ways. Type-only — +`sync.collection({ pk, mutations })` — recovers ADR-0010's precise +`op.cols` with no runtime cost (`pk` is checked against `keyof Row & string`). +Or **schema-first** — `sync.collection(zMessage, { pk, mutations })` — where `Row` +is *inferred from* a [Standard Schema](https://standardschema.dev/) value and the +schema's `~standard.validate` runs at **runtime** inside the compiled `authorize`, +before the author's `authorize`/`execute`, throwing (fail-loud, rejecting the +frame) on issues. + +This reverses ADR-0010's B3 rejection narrowly and deliberately. ADR-0010 rejected +a schema slot because it bought no *injection* safety (parameterised binding +already covers that) at a per-mutation hot-path cost. That still holds — so +validation is **opt-in** (no schema → no validator runs) and **scoped to where a +full row exists**: an `insert`'s `cols` and a command's `args` are validated; +`update` partials and `delete`s are **not** (no complete value to soundly check). +The slot's primary payoff is *inference + a typed client contract*, with runtime +validation as the opt-in bonus — not blanket hot-path validation. The interface is +the dependency-free `~standard` shape (`StandardSchemaV1`, exported); **no validator +runtime is imported** — zod/valibot/arktype all satisfy it, and an author who wants +none pays nothing. + +### D4: `type Api = typeof schema` carries end-to-end typing to the client + +Because the schema is a value whose type captures every collection's row and every +command's args/result, the client recovers the whole contract from a **type-only** +import (nothing server-side is bundled; Row/Args/Result ride phantom carriers): + +- `new WebSocketTransport({ url })` — typed connection. +- `transport.call.clearRoom({ before })` — a Proxy; name autocompletes, args and + result are checked. A void-args command is `transport.call.ping()`. +- `transport.sendCall("purge", { hard: true })` — the low-level typed call; + it builds the `call` frame and generates the txId via `crypto.randomUUID()` + internally (the app supplies no ulid/txId), and resolves with the command's + `result` on its `committed` receipt. The old `sendCall(frame)` signature is gone. +- `doCollectionOptions({ transport, table, getKey })` — infers the + row from `Api` + table, no runtime schema value needed; a zero-type-arg call + infers `Api` from the transport too. + +This is the thread ADR-0010 left dangling: the manifest typed the *server* alone; +the schema value types both ends from one source. + +### D5: Multi-DO is one transport per DO; commands are keyed by DO + +A schema (and thus an `Api`) describes exactly one DO. Two DOs means two schema +values, two `Api` types, two `WebSocketTransport`s — so `transport.call.*` is +naturally scoped to the commands of *that* DO, with no cross-DO command namespace +to disambiguate. This keeps the single-ordered-stream-per-DO model (ADR-0001) +intact: the client contract is per-connection, like the cursor. + +## DO wiring + +`registerSync` now takes the schema **value**, not a builder instance. It calls +`compileSchema(schema)` → `CompiledSync { collections, mutations, commands }` +(Maps keyed `table` / `` `${table}:${type}` `` / `name`), then runs the same +`initSchema` + `ensureTriggers` path as ADR-0007 — preserving D9 pk validation, +the reserved-`_sync_`-prefix guard, and the AFTER triggers. Dispatch is unchanged +in spirit: `handleMut` reads `registry.mutations.get(\`${collection}:${op.type}\`)` +and runs `authorize` → `execute` (inside `transactionSync`) → `afterCommit`; +`handleCall` reads `registry.commands.get(name)` and awaits `authorize` → +`execute`. The collection KEY is strictly the table name (interpolated into trigger +DDL; `/^[A-Za-z_][A-Za-z0-9_]*$/`, non-reserved). For synced writes **outside** a +mut/call handler, `runSyncedWrite` is still the path (ADR-0006). + +## Consequences + +- **One authoring site, one contract.** Row, `pk`, and the mutation trio sit + together on the collection; commands sit together under `commands`; the whole + thing is one value. ADR-0010's manifest/collection/mutation three-way split and + its `as Message` casts are gone, and the client is typed from the same value. +- **Hard break (pre-1.0, clean).** `new SyncRegistry().defineCollection/defineMutation/defineCommand` + is **removed** — no compatibility shim. Every DO subclass moves to + `defineSync` + `sync.schema` + `registerSync(schema)`; every typed client moves + to `transport.call` / the new `sendCall(name, args)`. Examples and the test-worker + move with it. +- **Closed mutations, open commands** — the shape now encodes the rule: if a write + isn't one-row insert/update/delete it is a command, and the compiler says so + (excess-property error on a 4th mutation key). +- **Opt-in validation, scoped.** No schema → zero validator on the hot path + (ADR-0010's objection preserved). Schema present → insert `cols` and command + `args` validated and inferred; update/delete deliberately unvalidated (no full + row). Dependency-free via Standard Schema; no zod runtime pulled in. +- **`authorize` divergence is now structural** (ADR-0012 D3): mutation-authorize + throws stay user-facing, command-authorize throws are sanitized — the two + helpers make that explicit rather than a shared-code accident. +- **Per-DO contract.** Multi-DO apps run one transport and one `Api` per DO; there + is no global command registry. Consistent with the per-connection cursor (0002). diff --git a/docs/adr/README.md b/docs/adr/README.md index bff4f18..c683d94 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,7 +8,7 @@ explains the displacement. | # | Title | Status | |---|---|---| | [0000](./0000-record-architecture-decisions.md) | Record architecture decisions | Accepted | -| [0001](./0001-sync-architecture.md) | Sync architecture: single-ordered-stream over a Durable Object | Accepted (amended by 0002) | +| [0001](./0001-sync-architecture.md) | Sync architecture: single-ordered-stream over a Durable Object | Accepted (amended by 0002; D11 builder superseded by 0014) | | [0002](./0002-adversarial-review-corrections.md) | Corrections from adversarial review: ordering, shaping, retention | Accepted (C5 retention refined by 0009) | | [0003](./0003-atomic-cursor-fetch.md) | Cursor load-more is one atomic fetch, not two | Accepted (naming amended by 0005) | | [0004](./0004-after-commit-hook.md) | Side effects go in a fire-and-forget `afterCommit`, not the transaction | Accepted | @@ -17,6 +17,7 @@ explains the displacement. | [0007](./0007-author-owned-schema-register-sync.md) | Author-owned schema; `registerSync` wires the sync | Accepted | | [0008](./0008-orphaned-cdc-triggers.md) | Orphaned CDC triggers when a collection is removed | Accepted | | [0009](./0009-changelog-time-retention.md) | Changelog time-based retention; reset stale reconnects | Accepted | -| [0010](./0010-typed-mutations-collection-manifest.md) | Typed mutations via a collection-row manifest on `SyncRegistry` | Accepted | +| [0010](./0010-typed-mutations-collection-manifest.md) | Typed mutations via a collection-row manifest on `SyncRegistry` | Accepted (manifest superseded by 0014) | | [0012](./0012-wire-input-hardening.md) | Wire-input hardening: frame-shape guards, inbound limits, sanitized execute errors | Accepted | | [0013](./0013-predicate-floor-one-evaluator.md) | Filtered-subscription membership: one evaluator is the source of truth; the floor is the verified-agreeing set | Accepted | +| [0014](./0014-object-sync-schema.md) | `defineSync`: one schema value, mutations on the collection, commands on the connection | Accepted (supersedes 0001 D11 builder; closes 0010 manifest) | diff --git a/src/client/do-collection.ts b/src/client/do-collection.ts index 80b0a2d..a1e7fd4 100644 --- a/src/client/do-collection.ts +++ b/src/client/do-collection.ts @@ -28,9 +28,23 @@ export class WriteOutsideSubError extends Error { } } +// --- Row inference from a schema Api (`typeof schema`) ---------------------- +// Mirrors the transport's command projection: structural-only, recovering a +// collection's Row from the phantom `__row` the server's `CollectionEntry` +// carries. The client needs NO runtime schema value — just the Api type. +type CollectionsOf = Api extends { collections: infer C } ? C : never +/** Table names declared on the schema Api. */ +export type CollectionName = keyof CollectionsOf & string +/** The Row type of collection `K` on the schema Api. */ +export type RowOf = K extends keyof CollectionsOf + ? CollectionsOf[K] extends { __row?: infer R } + ? R + : never + : never + export interface DoCollectionOptions { /** One transport per DO; shared by all collections on that DO. */ - transport: WebSocketTransport + transport: WebSocketTransport /** Collection (table) name on the DO. */ table: string /** Stable client-supplied key extractor (must match the server pk). */ @@ -79,9 +93,30 @@ function compilePredicate(where: unknown): (row: Record) => boo return (row) => toBooleanPredicate(evaluate(row)) } -export function doCollectionOptions( - opts: DoCollectionOptions, -): CollectionConfig { +/** Api-typed options: Row is inferred from the schema `Api` + `table`, so the + * client needs no runtime schema value. `getKey` and the row type follow. */ +export interface DoApiCollectionOptions> { + /** One transport per DO, parameterized by the same schema `Api`. */ + transport: WebSocketTransport + /** Collection (table) name on the DO — a key of the schema's collections. */ + table: K + /** Stable client-supplied key extractor (must match the server pk). */ + getKey: (row: RowOf) => string + /** Collection id; defaults to the table name. */ + id?: string + syncMode?: "eager" | "on-demand" + where?: unknown +} + +// Api-driven: `doCollectionOptions({ transport, table, getKey })` +// — Row inferred from the schema. Listed first so a zero-type-arg call infers +// Api from the transport rather than collapsing Row to the explicit-T overload. +export function doCollectionOptions>( + opts: DoApiCollectionOptions, +): CollectionConfig & object, string> +// Explicit-Row: `doCollectionOptions({ transport, table, getKey })`. +export function doCollectionOptions(opts: DoCollectionOptions): CollectionConfig +export function doCollectionOptions(opts: DoCollectionOptions): CollectionConfig { const { transport, table, getKey, where } = opts const syncMode = opts.syncMode ?? "eager" const eagerSubId = `${table}#${++subSeq}` @@ -184,7 +219,7 @@ export function doCollectionOptions( }) ensureBegin() for (const r of page) { - if (collection.get(getKey(r as T)) === undefined) write({ type: "insert", value: r }) + if (collection.get(getKey(r)) === undefined) write({ type: "insert", value: r }) } flush() } @@ -274,7 +309,7 @@ export function doCollectionOptions( onInsert: mutationFn, onUpdate: mutationFn, onDelete: mutationFn, - } as unknown as CollectionConfig + } as unknown as CollectionConfig } /** What our sync() returns: a cleanup fn (eager) or the on-demand handlers. */ diff --git a/src/client/index.ts b/src/client/index.ts index 8d725ab..35df955 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -14,4 +14,9 @@ export { } from "./transport.ts" export type { SubHandler, TransportOptions, WebSocketLike } from "./transport.ts" export { doCollectionOptions, WriteOutsideSubError } from "./do-collection.ts" -export type { DoCollectionOptions } from "./do-collection.ts" +export type { + CollectionName, + DoApiCollectionOptions, + DoCollectionOptions, + RowOf, +} from "./do-collection.ts" diff --git a/src/client/transport.ts b/src/client/transport.ts index 091f2a8..6defdcc 100644 --- a/src/client/transport.ts +++ b/src/client/transport.ts @@ -71,7 +71,20 @@ interface TxWaiter { timer: ReturnType } -export class WebSocketTransport { +// --- Typed-command projection over the schema Api (`typeof schema`) --------- +// Structural-only: the client imports the Api *as a type*, so these recover the +// command map and each command's Args/Result from the phantom carriers the +// server's `CommandEntry` attaches — no server import, nothing at runtime. +type CommandsOf = Api extends { commands: infer C } ? C : Record +type CommandName = keyof CommandsOf & string +type ArgsOf = Entry extends { __args?: infer A } ? A : never +type ResultOf = Entry extends { __result?: infer R } ? R : never +/** The `transport.call.*` proxy: one method per command, args + result typed. */ +type CallProxy = { + [K in keyof CommandsOf]: (args: ArgsOf[K]>) => Promise[K]>> +} + +export class WebSocketTransport { private ws: WebSocketLike | null = null private connectPromise: Promise | null = null private readonly codec: FrameCodec @@ -236,10 +249,33 @@ export class WebSocketTransport { return this.sendAwaitingReceipt(frame, frame.txId) } - sendCall(frame: Extract): Promise<{ result?: unknown }> { - return this.sendAwaitingReceipt(frame, frame.txId) + /** + * Invoke a server command by name. Builds the `call` frame internally and + * generates the txId (`crypto.randomUUID()`) — the app no longer supplies one. + * Name + args are checked and the result is inferred against the schema `Api`. + * Resolves with the command's result when its `committed` receipt arrives. + */ + async sendCall>( + name: K, + args: ArgsOf[K]>, + ): Promise[K]>> { + const txId = crypto.randomUUID() + const { result } = await this.sendAwaitingReceipt({ t: "call", txId, name, args }, txId) + return result as ResultOf[K]> } + /** Sugar over `sendCall`: `transport.call.clearRoom({ … })`. One method per + * command on the schema `Api`; a Proxy forwarding to `sendCall`. */ + readonly call: CallProxy = new Proxy( + {}, + { + get: + (_t, name: string) => + (args: unknown): Promise => + this.sendCall(name as CommandName, args as ArgsOf[CommandName]>), + }, + ) as CallProxy + /** One-shot page fetch; resolves with the page's rows. No live subscription. */ async fetch(frame: Extract): Promise> { await this.connect() diff --git a/src/server/changes.ts b/src/server/changes.ts index 7b21ca6..b48621a 100644 --- a/src/server/changes.ts +++ b/src/server/changes.ts @@ -52,7 +52,7 @@ export function initSchema(sql: SqlStorage): void { /** * Install AFTER INSERT/UPDATE/DELETE triggers copying change events into * `_sync_changes`. Idempotent. `tbl`/`pk` MUST be validated identifiers - * (the SyncRegistry enforces this via `assertValidCollection`) — they are + * (`compileSchema` enforces this via `assertValidCollection`) — they are * interpolated into DDL. Identifiers are double-quoted for consistency with the * rest of the codebase (`sql-compiler.ts`, `ensureTriggers`' DROP), though the * regex gate remains the real safety net. The `'${tbl}'` string literal (the @@ -252,7 +252,7 @@ export function readChangesSinceFor( } /** Current rows for a set of keys, for hydrating deltas. `tbl`/`pk` are - * validated identifiers (the SyncRegistry enforces this). Queries in chunks + * validated identifiers (`compileSchema` enforces this). Queries in chunks * of 64 to avoid SQLite bound-parameter limits and eliminate the N+1 pattern: * a reconnect catch-up over 500 keys now issues ⌈500/64⌉ = 8 queries instead * of 500. Identifiers are quoted; values are bound parameters. */ diff --git a/src/server/index.ts b/src/server/index.ts index 4cdd99b..15c13f1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,15 +4,29 @@ // diffs. Imports the workerd runtime (`cloudflare:workers`); not for browsers. // // - SyncDurableObject: hibernating-WebSocket base class. -// - SyncRegistry: defineCollection / defineMutation / defineCommand. +// - defineSync: the object-schema authoring API (collection/command/schema). -export { SyncRegistry } from "./registry.ts" +export { assertValidCollection, compileSchema, defineSync } from "./registry.ts" export type { CollectionDef, + CollectionEntry, + CollectionInput, CommandCtx, - CommandDef, + CommandEntry, + CommandInput, + CompiledSync, + DeleteDef, + DeleteOp, + InsertDef, + InsertOp, MutationCtx, + Mutations, OpFor, - MutationDef, + RuntimeCommandDef, + RuntimeMutationDef, + StandardSchemaV1, + SyncSchema, + UpdateDef, + UpdateOp, } from "./registry.ts" export { SyncDurableObject } from "./sync-do.ts" diff --git a/src/server/registry.ts b/src/server/registry.ts index 607df2a..1132c25 100644 --- a/src/server/registry.ts +++ b/src/server/registry.ts @@ -1,6 +1,24 @@ -// Collection registry. For M1 it holds collection definitions and enforces the -// client-supplied-key rule (ADR-0001 D9). defineMutation/defineCommand arrive -// with the sync + confirmation milestones. +// Sync schema — the object-shaped authoring API (ADR-0014). +// +// An app authors its DO's sync surface with `defineSync()`, which +// binds the identity (User) and binding-env (Env) once and hands back three +// co-located helpers: +// +// const sync = defineSync() +// const schema = sync.schema({ collections: { … }, commands: { … } }) +// export type Api = typeof schema // the WHOLE client contract +// +// `schema(...)` is a VALUE: the DO registers it (`registerSync`), and the client +// imports it *as a type only* (`typeof schema`) to drive `transport.call.*` and +// `doCollectionOptions`. Each collection entry carries its Row in the type — via +// an explicit generic (`collection({ … })`) OR inferred from the row schema +// on its insert mutation (`collection({ mutations: { insert: { schema } } })`). +// The collection KEY is the DB table name. +// +// `registerSync` compiles the schema value into the flat dispatch structures the +// DO consumes (collections `{table,pk}`, mutations keyed `${table}:${type}`, +// commands keyed by name) — see `compileSchema`. The wire/dispatch semantics are +// unchanged; this file only changes how the author DECLARES them. import type { SqlStorage } from "@cloudflare/workers-types" import type { MutOp, RowOp } from "../wire/frames.ts" @@ -8,35 +26,62 @@ import { SYNC_PREFIX } from "./changes.ts" const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/ +// --------------------------------------------------------------------------- +// Standard Schema — the dependency-free `~standard` interface that zod/valibot/ +// arktype all satisfy. We never import a validator. +// +// On THIS branch a schema is used for TYPE inference only: a row schema on +// `insert.schema` infers a collection's Row, and `command(schema, …)` infers a +// command's Args. Runtime validation against these schemas is NOT wired here; it +// lands in the stacked validation PR (see the tombstone notes in +// `compileMutation`/`compileCommand`). When it lands it is a validation GATE, not +// a parser: the handler receives the original wire value, never the schema's +// parsed output, so a schema must not rely on transforms/defaults/coercion (input +// must equal output). Rewriting a row the client already applied optimistically +// would manufacture divergence, and a pk rewrite would break optimistic-id == +// confirmed-id (ADR-0001 D9). +// --------------------------------------------------------------------------- +export interface StandardSchemaV1 { + readonly "~standard": { + readonly version: 1 + readonly vendor: string + readonly validate: (value: unknown) => StandardResult | Promise> + readonly types?: { readonly input: unknown; readonly output: Output } + } +} +type StandardResult = + | { readonly value: Output; readonly issues?: undefined } + | { readonly issues: ReadonlyArray<{ readonly message: string }> } +type InferSchema = S extends StandardSchemaV1 ? O : never + +// --------------------------------------------------------------------------- +// Collection identity (runtime) + per-op shapes. +// --------------------------------------------------------------------------- export interface CollectionDef { /** Table name; also the collection's wire identity. The author creates the * table themselves (migration); the framework only reads + emits it. */ table: string - /** Primary-key column — must be a client-supplied TEXT key (ULID/UUIDv7). - * Enforced against the actual table by `registerSync` (ADR-0007). */ + /** Primary-key column — a client-supplied TEXT key (ULID/UUIDv7). Enforced + * against the actual table by `registerSync` (ADR-0007). */ pk: string } -/** - * Per-op shape of `op` seen by a mutation handler (ADR-0010), discriminated by - * `type`: an insert carries the full row, an update a top-level partial patch - * (ADR-0002 C6), a delete only the key. `Row` is the collection's row type from - * the `SyncRegistry` manifest (`unknown` when the collection is untyped). - */ +/** Per-op shape of `op`, discriminated by `type`: an insert carries the full + * row, an update a top-level partial patch (ADR-0002 C6), a delete only the + * key. Kept as a public alias for back-compat; the authoring API below uses + * the explicit `InsertOp`/`UpdateOp`/`DeleteOp` triple. */ export type OpFor = T extends "insert" - ? { type: "insert"; key: string; cols: Row } + ? InsertOp : T extends "update" - ? { type: "update"; key: string; cols: Partial } - : { type: "delete"; key: string; cols?: undefined } + ? UpdateOp + : DeleteOp -/** The pk must be an actual column when the row type is known; any string for an - * untyped collection (`keyof unknown` is `never`). */ -type PkOf = [keyof Row] extends [never] ? string : keyof Row & string +export type InsertOp = { type: "insert"; key: string; cols: Row } +export type UpdateOp = { type: "update"; key: string; cols: Partial } +export type DeleteOp = { type: "delete"; key: string; cols?: undefined } -/** Context for a mutation handler. `env` is the DO's binding env, so handlers - * can reach external resources (R2/KV/services) — essential for `afterCommit` - * side effects and useful in `authorize`. `execute` runs inside - * `transactionSync`, so it cannot await env, but may read synchronous config. +/** Context for a mutation handler. `env` is the DO's binding env; `execute` runs + * inside `transactionSync`, so it must be synchronous (ADR-0001 D11/C6). * `TOp` is the typed `op` (defaults to the erased wire `MutOp`). */ export interface MutationCtx { user: TUser @@ -45,108 +90,291 @@ export interface MutationCtx { env: Env } -export interface MutationDef { - collection: string - type: RowOp - /** Runs BEFORE the transaction; may be async (read other rows, call out). - * Throw to deny — the frame is rejected and nothing is applied. */ - authorize?: (ctx: MutationCtx) => void | Promise - /** Runs INSIDE `transactionSync` — MUST be synchronous (ADR-0001 D11/C6). */ - execute: (ctx: MutationCtx) => void - /** - * Fire-and-forget async post-work, run via `ctx.waitUntil` AFTER the mutation - * commits and its receipt is sent — never blocking the client. This is the - * sanctioned home for external side effects a synchronous `execute` can't do - * (delete an R2 object, enqueue a job). It receives the committed `env`/`sql`. - * - * It has no retry and no ordering guarantee: a thrown error or a DO eviction - * mid-effect just drops THIS invocation. Make the work idempotent and - * level-triggered (query "what still needs doing", act, mark done) so a later - * trigger — the next such mutation, or a boot-time sweep in your DO — finishes - * whatever a dropped invocation left. Don't put the durable state change here; - * that belongs in `execute`. - */ - afterCommit?: (ctx: MutationCtx) => unknown | Promise -} - -/** Context for a command handler. `execute` runs outside any transaction. */ -export interface CommandCtx { +/** Context for a command handler. `execute` runs OUTSIDE any transaction (so it + * may be async). `Args` defaults to `unknown` (the erased runtime view). */ +export interface CommandCtx { user: TUser - args: unknown + args: Args sql: SqlStorage env: Env } -export interface CommandDef { - name: string - authorize?: (ctx: CommandCtx) => void | Promise - /** Side-effecting command; may be async (so external effects can run inline, - * unlike a mutation's synchronous execute). Result is returned on `committed`. */ - execute: (ctx: CommandCtx) => unknown | Promise +// --------------------------------------------------------------------------- +// Authoring types — the closed mutation trio + collection/command entries. +// +// `insert` and `update` each carry an optional `schema` (the row schema, and the +// patch schema). On this branch the schema types `op.cols`; the stacked PR wires +// runtime validation against it. +// --------------------------------------------------------------------------- +export interface InsertDef { + /** Row schema. Validates the full-row `cols` (in the validation PR) and, when + * used as the inference source, types the collection's Row. */ + schema?: StandardSchemaV1 + authorize?: (ctx: MutationCtx>) => void | Promise + execute: (ctx: MutationCtx>) => void + afterCommit?: (ctx: MutationCtx>) => unknown | Promise +} +export interface UpdateDef { + /** Patch schema. The author supplies a PARTIAL schema (e.g. `Row.partial()`), + * since an update carries a top-level partial patch, not a full row. */ + schema?: StandardSchemaV1> + authorize?: (ctx: MutationCtx>) => void | Promise + execute: (ctx: MutationCtx>) => void + afterCommit?: (ctx: MutationCtx>) => unknown | Promise +} +export interface DeleteDef { + authorize?: (ctx: MutationCtx) => void | Promise + execute: (ctx: MutationCtx) => void + afterCommit?: (ctx: MutationCtx) => unknown | Promise +} + +/** The CLOSED mutation trio. A fourth key is unrepresentable, so excess-property + * checking on the object literal rejects e.g. an `archive` mutation. */ +export interface Mutations { + insert?: InsertDef + update?: UpdateDef + delete?: DeleteDef +} + +/** What the type-only `collection(...)` form accepts. */ +export interface CollectionInput { + pk: keyof Row & string + mutations?: Mutations +} + +/** An insert def whose `schema` is REQUIRED — the Row inference source for the + * schema-first `collection({ mutations: { insert: { schema } } })` form. */ +interface InsertWithSchema { + schema: S + authorize?: (ctx: MutationCtx>>) => void | Promise + execute: (ctx: MutationCtx>>) => void + afterCommit?: (ctx: MutationCtx>>) => unknown | Promise +} + +/** What the schema-first `collection(...)` form accepts: Row is inferred from + * `mutations.insert.schema`, and flows to `pk`, `update`, and the entry's Row. */ +interface CollectionInputFromInsert { + pk: keyof InferSchema & string + mutations: { + insert: InsertWithSchema + update?: UpdateDef> + delete?: DeleteDef + } +} + +/** A collection entry: the authored config plus the Row carried in the type. The + * phantom `__row` lets the client recover Row from `typeof schema`. */ +export interface CollectionEntry { + pk: keyof Row & string + mutations?: Mutations + /** phantom — type-only carrier of Row for client inference. */ + readonly __row?: Row +} + +/** What `command(...)` accepts: a bare execute fn, or `{ authorize?, execute }`. */ +export type CommandInput = + | ((ctx: CommandCtx) => Result) + | { + authorize?: (ctx: CommandCtx) => void | Promise + execute: (ctx: CommandCtx) => Result + } + +/** A command entry: carries Args and the (awaited) Result in the type. The + * phantoms let the client recover both from `typeof schema`. */ +export interface CommandEntry { + schema?: StandardSchemaV1 + authorize?: (ctx: CommandCtx) => void | Promise + execute: (ctx: CommandCtx) => Result | Promise + /** phantoms — type-only carriers for client inference. */ + readonly __args?: Args + readonly __result?: Result +} + +/** The schema VALUE produced by `defineSync().schema(...)`; `typeof` it for the + * client Api. Structurally typed so a concrete schema value is assignable. */ +export interface SyncSchema { + collections: Record> + commands: Record> } +// --------------------------------------------------------------------------- +// The bound factory. +// --------------------------------------------------------------------------- /** - * The author-facing mutation definition: like `MutationDef`, but its handlers - * receive a `Row`- and op-typed `op` (ADR-0010). `SyncRegistry` accepts this and - * stores it as the erased `MutationDef` — dispatch is untyped, so the erasure is - * sound (the wire delivers exactly this shape at runtime). + * Bind `User`/`Env` once and get the three co-located authoring helpers. + * + * const sync = defineSync() + * sync.collection({ pk: "id", mutations: { insert: { … } } }) // explicit Row + * sync.collection({ pk: "id", mutations: { insert: { schema: zMessage, … } } }) // Row inferred + * sync.command<{ before?: number }>()(({ args }) => { … }) // Result inferred + * sync.command(zArgs, ({ args }) => { … }) // Args inferred + * sync.schema({ collections, commands }) */ -type MutationInput = { - collection: Name - type: T - authorize?: (ctx: MutationCtx>) => void | Promise - execute: (ctx: MutationCtx>) => void - afterCommit?: (ctx: MutationCtx>) => unknown | Promise +export function defineSync(): { + collection: CollectionFactory + command: CommandFactory + schema: SchemaFactory +} { + // --- collection: Row inferred from insert.schema, OR explicit type-only Row --- + function collection( + def: CollectionInputFromInsert, + ): CollectionEntry> + function collection(def: CollectionInput): CollectionEntry + function collection(def: unknown): CollectionEntry { + return { ...(def as object) } as CollectionEntry + } + + // --- command: type-only Args (curried so Result infers) OR Schema-inferred Args --- + function command(): ( + input: CommandInput, + ) => CommandEntry> + function command( + schema: S, + input: CommandInput, Result>, + ): CommandEntry, Awaited> + function command(a?: unknown, b?: unknown): unknown { + if (a !== undefined && b !== undefined) { + return { ...normalizeCommand(b), schema: a } as unknown + } + return (input: unknown) => normalizeCommand(input) + } + + // Overloaded so a commandless schema gets an EMPTY command map (keyof = never) + // rather than the loose `Record` an optional generic would infer — + // otherwise `transport.call.anything()` would type-check against a DO that has + // no commands. + function schema>>(config: { + collections: Cols + commands?: undefined + }): { collections: Cols; commands: Record } + function schema< + Cols extends Record>, + Cmds extends Record>, + >(config: { collections: Cols; commands: Cmds }): { collections: Cols; commands: Cmds } + function schema(config: { + collections: Record> + commands?: Record> + }): { collections: unknown; commands: unknown } { + return { collections: config.collections, commands: config.commands ?? {} } + } + + return { collection, command, schema } +} + +/** Normalize a `CommandInput` (bare fn | object) to a `{ authorize?, execute }`. */ +function normalizeCommand(input: unknown): { authorize?: unknown; execute: unknown } { + if (typeof input === "function") return { execute: input } + return input as { authorize?: unknown; execute: unknown } +} + +// Helper aliases so the factory return is nameable (and re-exportable). +interface CollectionFactory { + (def: CollectionInputFromInsert): CollectionEntry> + (def: CollectionInput): CollectionEntry +} +interface CommandFactory { + (): ( + input: CommandInput, + ) => CommandEntry> + ( + schema: S, + input: CommandInput, Result>, + ): CommandEntry, Awaited> +} +interface SchemaFactory { + >>(config: { + collections: Cols + commands?: undefined + }): { collections: Cols; commands: Record } + < + Cols extends Record>, + Cmds extends Record>, + >(config: { + collections: Cols + commands: Cmds + }): { collections: Cols; commands: Cmds } } +// --------------------------------------------------------------------------- +// Runtime dispatch structures — what the DO consumes. The authoring types above +// are erased to these by `compileSchema`; dispatch is untyped and the erasure is +// sound (the wire delivers exactly the per-op shape the handler expects). +// --------------------------------------------------------------------------- +export interface RuntimeMutationDef { + authorize?: (ctx: MutationCtx) => void | Promise + execute: (ctx: MutationCtx) => void + afterCommit?: (ctx: MutationCtx) => unknown | Promise +} +export interface RuntimeCommandDef { + authorize?: (ctx: CommandCtx) => void | Promise + execute: (ctx: CommandCtx) => unknown | Promise +} + +/** The compiled dispatch tables the DO holds. */ +export interface CompiledSync { + readonly collections: Map + /** Keyed by `${table}:${type}`. */ + readonly mutations: Map> + readonly commands: Map> +} + +const ROW_OPS = ["insert", "update", "delete"] as const + /** - * `TCols` is the collection-row manifest (table name → row type), declared once - * at construction (ADR-0010): `new SyncRegistry()`. - * It types both `defineCollection` (pk ∈ keyof Row) and `defineMutation` - * (op.cols per row + op). Defaults to `Record`, so an untyped - * `new SyncRegistry()` still compiles (handlers cast, as before). + * Compile an authored schema value into the flat dispatch tables, validating each + * collection's identifiers (ADR-0007/0008). + * + * The per-op Standard Schema slots (`insert.schema`, `update.schema`, command + * `schema`) type the author's `cols`/`args`, but on this branch they are NOT + * enforced at runtime — runtime validation is wired in the stacked validation PR + * (see the tombstone notes in `compileMutation`/`compileCommand`). */ -export class SyncRegistry< - TUser = unknown, - Env = unknown, - TCols extends Record = Record, -> { - readonly collections = new Map() - /** Keyed by `${collection}:${type}`. */ - readonly mutations = new Map>() - readonly commands = new Map>() - - defineCollection(def: { - table: Name - pk: PkOf> - }): this { +export function compileSchema(schema: SyncSchema): CompiledSync { + const collections = new Map() + const mutations = new Map>() + const commands = new Map>() + + for (const [table, entry] of Object.entries(schema.collections)) { + const def: CollectionDef = { table, pk: entry.pk } assertValidCollection(def) - if (this.collections.has(def.table)) { - throw new Error(`collection '${def.table}' is already defined`) + if (collections.has(table)) throw new Error(`collection '${table}' is already defined`) + collections.set(table, def) + const muts = entry.mutations + if (!muts) continue + for (const type of ROW_OPS) { + const m = muts[type] + if (!m) continue + const perOpSchema = (m as { schema?: StandardSchemaV1 }).schema + mutations.set(`${table}:${type}`, compileMutation(type, m as RuntimeMutationDef, perOpSchema)) } - this.collections.set(def.table, def) - return this } - defineMutation( - def: MutationInput>, - ): this { - if (!this.collections.has(def.collection)) { - throw new Error(`defineMutation: unknown collection '${def.collection}' — define the collection first`) - } - const key = `${def.collection}:${def.type}` - if (this.mutations.has(key)) throw new Error(`mutation '${key}' is already defined`) - // Erase to the stored, untyped def: runtime dispatch passes a wire `MutOp`, - // which is exactly the shape the typed handler expects (ADR-0010). - this.mutations.set(key, def as unknown as MutationDef) - return this + for (const [name, entry] of Object.entries(schema.commands)) { + if (commands.has(name)) throw new Error(`command '${name}' is already defined`) + commands.set(name, compileCommand(entry as CommandEntry)) } - defineCommand(def: CommandDef): this { - if (this.commands.has(def.name)) throw new Error(`command '${def.name}' is already defined`) - this.commands.set(def.name, def) - return this - } + return { collections, mutations, commands } +} + +function compileMutation( + type: RowOp, + m: RuntimeMutationDef, + schema: StandardSchemaV1 | undefined, +): RuntimeMutationDef { + // TOMBSTONE (base branch): the per-op `schema` (insert full-row, update partial) + // types `op.cols` for the author, but runtime validation is NOT wired here. It + // lands in the stacked validation PR, where this gates the write on `schema`. + // On this branch the schema is inert at runtime. + void type + void schema + return m +} + +function compileCommand(entry: CommandEntry): RuntimeCommandDef { + // TOMBSTONE (base branch): the command `schema` types `args`; runtime validation + // is wired in the stacked validation PR. + return { authorize: entry.authorize, execute: entry.execute } } /** @@ -154,8 +382,7 @@ export class SyncRegistry< * interpolated raw into trigger DDL and `SELECT`s, so they must be safe * identifiers and must not collide with framework tables. The structural * constraint — the pk is a sole TEXT client-supplied key (no AUTOINCREMENT, - * D9) — is enforced against the ACTUAL table by `registerSync` (ADR-0007), - * since the author owns table creation and we no longer see a DDL string. + * D9) — is enforced against the ACTUAL table by `registerSync` (ADR-0007). */ export function assertValidCollection(def: CollectionDef): void { const { table, pk } = def diff --git a/src/server/sync-do.ts b/src/server/sync-do.ts index 0caecdf..fc2d863 100644 --- a/src/server/sync-do.ts +++ b/src/server/sync-do.ts @@ -31,16 +31,16 @@ import { } from "./changes.ts" import { Broadcaster } from "./broadcast.ts" import { decodeResult, encodeResult, lookupTx, recordTx, type SeenTx, sweepDedup } from "./dedup.ts" -import type { SyncRegistry } from "./registry.ts" +import { compileSchema, type CompiledSync, type SyncSchema } from "./registry.ts" import { andPredicates, compileSubsetQuery, UnsupportedPredicateError } from "./sql-compiler.ts" import { SubscriptionRegistry, type Sub } from "./subscriptions.ts" export abstract class SyncDurableObject extends DurableObject { - /** Set by `registerSync` — the collections/mutations/commands this DO serves. */ - #registry: SyncRegistry | undefined + /** Set by `registerSync` — the compiled dispatch tables this DO serves. */ + #registry: CompiledSync | undefined - /** The registered registry. Throws if `registerSync` hasn't run yet (ADR-0007). */ - protected get registry(): SyncRegistry { + /** The compiled schema. Throws if `registerSync` hasn't run yet (ADR-0007). */ + protected get registry(): CompiledSync { if (!this.#registry) { throw new Error( "sync not registered — call this.registerSync(registry) in your constructor's " + @@ -112,10 +112,11 @@ export abstract class SyncDurableObject extends * `blockConcurrencyWhile`, after migrating. Idempotent; re-callable to update * the whole trigger state when the registry changes. */ - protected registerSync(registry: SyncRegistry): void { + protected registerSync(schema: SyncSchema): void { + const compiled = compileSchema(schema) initSchema(this.sql) - ensureTriggers(this.sql, registry.collections.values()) - this.#registry = registry + ensureTriggers(this.sql, compiled.collections.values()) + this.#registry = compiled } /** diff --git a/tests/registry-types.ts b/tests/registry-types.ts index 02e1099..b77b67a 100644 --- a/tests/registry-types.ts +++ b/tests/registry-types.ts @@ -1,11 +1,11 @@ -// Type-level tests for ADR-0010 (typed mutations via the collection manifest). -// NOT a runtime test — the filename is intentionally not `*.test.ts`, so vitest -// ignores it while `tsc -p tsconfig.json` type-checks it. `@ts-expect-error` -// marks lines that MUST NOT compile; an unmarked line that fails to compile is -// a red type-test. This pins the author-facing typing the same way a runtime -// test pins behaviour. +// Type-level tests for ADR-0014 (the object-schema authoring API + typed +// mutations/commands). NOT a runtime test — the filename is intentionally not +// `*.test.ts`, so vitest ignores it while `tsc -p tsconfig.json` type-checks it. +// `@ts-expect-error` marks lines that MUST NOT compile; an unmarked line that +// fails to compile is a red type-test. This pins the author-facing typing the +// same way a runtime test pins behaviour. -import { SyncRegistry } from "../src/server/registry.ts" +import { defineSync } from "../src/server/registry.ts" interface Claims { userId: string @@ -24,68 +24,64 @@ interface FileRow { name: string } -// The manifest types both call sites. -const r = new SyncRegistry() +const sync = defineSync() -// defineCollection: table ∈ manifest, pk ∈ keyof Row. -r.defineCollection({ table: "messages", pk: "id" }) -r.defineCollection({ table: "files", pk: "id" }) +// collection: pk must be an actual column of Row. +sync.collection({ pk: "id" }) // @ts-expect-error pk must be an actual column of Message -r.defineCollection({ table: "messages", pk: "nope" }) -// @ts-expect-error table must be a declared collection -r.defineCollection({ table: "ghosts", pk: "id" }) +sync.collection({ pk: "nope" }) -// insert → cols is the full row. -r.defineMutation({ - collection: "messages", - type: "insert", - execute: ({ op }) => { - const a: string = op.cols.author - void a +// Per-op typing: insert → cols is the full row; update → Partial; delete → +// no cols, just key. authorize + afterCommit see the same typed op. +sync.collection({ + pk: "id", + mutations: { + insert: { + authorize: ({ op }) => { + const a: string = op.cols.author + void a + }, + execute: ({ op }) => { + const a: string = op.cols.author + void a + }, + afterCommit: ({ op }) => op.key, // key: string + }, + update: { + execute: ({ op }) => { + const a: string | undefined = op.cols.author + void a + }, + }, + delete: { + // @ts-expect-error delete carries no cols + execute: ({ op }) => void op.cols.author, + }, }, }) -// update → cols is Partial. -r.defineMutation({ - collection: "messages", - type: "update", - execute: ({ op }) => { - const a: string | undefined = op.cols.author - void a +// The mutation trio is CLOSED: a fourth key is an excess-property error. +sync.collection({ + pk: "id", + mutations: { + // @ts-expect-error `archive` is not a member of the insert/update/delete trio + archive: { execute: () => {} }, }, }) -// delete → no cols, just key. -r.defineMutation({ - collection: "messages", - type: "delete", - // @ts-expect-error delete carries no cols - execute: ({ op }) => void op.cols.author, -}) - -// authorize + afterCommit get the same typed op. -r.defineMutation({ - collection: "files", - type: "insert", - authorize: ({ op }) => { - const n: string = op.cols.name - void n - }, - execute: ({ op }) => op.cols.name.toUpperCase(), - afterCommit: ({ op }) => op.key, // key: string -}) - -// @ts-expect-error unknown collection name is rejected -r.defineMutation({ collection: "ghosts", type: "insert", execute: () => {} }) +// command: type-only Args is CURRIED (call twice) so Result infers from the +// return; `args` carries the declared Args. +const echo = sync.command<{ n: number }>()(({ args }) => ({ echoed: args.n })) +// @ts-expect-error args.missing is not a declared arg +sync.command<{ n: number }>()(({ args }) => args.missing) -// Untyped fallback: a 2-arg SyncRegistry still compiles; cols is `unknown` (cast, as before). -const untyped = new SyncRegistry() -untyped.defineCollection({ table: "anything", pk: "whatever" }) -untyped.defineMutation({ - collection: "anything", - type: "insert", - execute: ({ op }) => { - const m = op.cols as Message - void m.author +// schema(): the collection KEY is the table name; `commands` is optional but +// carries Result/Args inference into the Api when present. +const schema = sync.schema({ + collections: { + messages: sync.collection({ pk: "id" }), + files: sync.collection({ pk: "id" }), }, + commands: { echo }, }) +void schema diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 8c04896..79ee87b 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -1,34 +1,36 @@ import { describe, expect, it } from "vitest" -import { SyncRegistry } from "../src/server/registry.ts" +import { compileSchema, type SyncSchema } from "../src/server/registry.ts" -// WHY: `{table, pk}` are interpolated raw into trigger DDL and SELECTs, so -// defineCollection rejects unsafe identifiers, the reserved `_sync_` prefix, and -// duplicate collections at registration. The structural D9 rule (the pk is a sole +// WHY: `{table, pk}` are interpolated raw into trigger DDL and SELECTs, so the +// registration path (registerSync -> compileSchema) rejects unsafe identifiers +// and the reserved `_sync_` prefix. The structural D9 rule (the pk is a sole // TEXT client-supplied key) is now enforced against the REAL table by // registerSync -> assertSyncCompatible (see assert-sync-compatible.test.ts), // since the author owns the schema (ADR-0007). +// +// The object-schema API (ADR-0014) keys a collection by its table name, so the +// table IS the object key: `compileSchema` is the guard site, and a one-entry +// schema value is the unit under test. (The old imperative "define the same +// collection twice" case is structurally unrepresentable now — duplicate object +// keys collapse — so that runtime guard can no longer be reached or asserted.) -const define = (def: { table: string; pk: string }) => () => new SyncRegistry().defineCollection(def) +const compile = (table: string, pk: string) => () => + compileSchema({ collections: { [table]: { pk } }, commands: {} } as SyncSchema) -describe("SyncRegistry.defineCollection — identifier + registration guards", () => { +describe("compileSchema — identifier + registration guards", () => { it("accepts a valid { table, pk }", () => { - expect(define({ table: "messages", pk: "id" })).not.toThrow() + expect(compile("messages", "id")).not.toThrow() }) it("rejects the reserved _sync_ table prefix", () => { - expect(define({ table: "_sync_x", pk: "id" })).toThrow(/reserved/) + expect(compile("_sync_x", "id")).toThrow(/reserved/) }) it("rejects an invalid table identifier (no SQL injection into trigger DDL)", () => { - expect(define({ table: "bad name", pk: "id" })).toThrow(/invalid table/) + expect(compile("bad name", "id")).toThrow(/invalid table/) }) it("rejects an invalid pk identifier", () => { - expect(define({ table: "t", pk: "1bad" })).toThrow(/invalid pk/) - }) - - it("rejects defining the same collection twice", () => { - const r = new SyncRegistry().defineCollection({ table: "m", pk: "id" }) - expect(() => r.defineCollection({ table: "m", pk: "id" })).toThrow(/already defined/) + expect(compile("t", "1bad")).toThrow(/invalid pk/) }) }) diff --git a/tests/sync-write.test.ts b/tests/sync-write.test.ts index f2a3666..a141984 100644 --- a/tests/sync-write.test.ts +++ b/tests/sync-write.test.ts @@ -44,6 +44,16 @@ function send(ws: WebSocket, frame: ClientFrame): void { ws.send(codec.encode(frame)) } +/** Poll until `pred` holds — for a non-originating subscriber's deltas, which + * arrive on the coalescer tick (no `committed` to await on). */ +async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!pred()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout") + await new Promise((r) => setTimeout(r, 5)) + } +} + /** Subscribe and wait for the initial snap-end so later frames are post-sub. */ async function subscribe(ws: WebSocket, subId: string): Promise { send(ws, { t: "sub", subId, collection: "messages" }) @@ -117,6 +127,44 @@ describe("write path: single-stream confirmation (M3)", () => { ws.close() }) + it("a SQL-writing command broadcasts its row changes AND returns a result", async () => { + // WHY: a command isn't only RPC — its `sql` writes hit the same CDC triggers + // a mutation's do, so they fan out to OTHER subscribers as ordinary deltas, + // while the caller also gets a result. This pins that dual path (the chat + // `clearRoom` example relies on it): B never originated the call yet must see + // the deletes; A gets the count back. A typed mutation can do neither half. + const a = await openWs("/sync/w-cmd-clear") + const b = await openWs("/sync/w-cmd-clear") // same room → same DO + await subscribe(a, "sa") + await subscribe(b, "sb") + + // B is a passive subscriber: collect every delta it receives (it never + // originates a write, so it gets `d` frames, never `committed`). + const bDeltas: Array> = [] + b.addEventListener("message", (e: MessageEvent) => { + const f = codec.decode(e.data as ArrayBuffer) as ServerFrame + if (f.t === "d") bDeltas.push(f) + }) + + // Seed two rows via A; wait until B has observed both inserts. + send(a, { t: "mut", txId: "i1", collection: "messages", ops: [{ type: "insert", key: "a", cols: { id: "a", body: "one" } }] }) + send(a, { t: "mut", txId: "i2", collection: "messages", ops: [{ type: "insert", key: "b", cols: { id: "b", body: "two" } }] }) + await waitFor(() => bDeltas.filter((d) => d.op === "insert").length === 2) + + // A invokes the SQL-writing command. + send(a, { t: "call", txId: "c1", name: "clearMessages", args: {} }) + const frames = await collectUntil(a, (f) => f.t === "committed" && f.txId === "c1") + const committed = frames.find((f) => f.t === "committed") as Extract + expect(committed.result).toEqual({ deleted: 2 }) // the result channel + + // …and B (which never called it) sees both rows removed as delete deltas. + await waitFor(() => bDeltas.filter((d) => d.op === "delete").length === 2) + expect(new Set(bDeltas.filter((d) => d.op === "delete").map((d) => d.key))).toEqual(new Set(["a", "b"])) + + a.close() + b.close() + }) + it("fans an update and a delete to a subscriber as deltas", async () => { const ws = await openWs("/sync/w-upd") await subscribe(ws, "s1") diff --git a/tests/test-worker.ts b/tests/test-worker.ts index b629804..51ad97e 100644 --- a/tests/test-worker.ts +++ b/tests/test-worker.ts @@ -2,7 +2,7 @@ // miniflare.durableObjects, and routes WebSocket upgrades to the sync DO. import { DurableObject } from "cloudflare:workers" -import { SyncRegistry } from "../src/server/registry.ts" +import { defineSync } from "../src/server/registry.ts" import { SyncDurableObject } from "../src/server/sync-do.ts" /** Bare DO for the M1 CDC tests; they drive storage via runInDurableObject. */ @@ -20,6 +20,89 @@ interface MsgRow { body: string } +interface FileRow { + id: string + name: string +} + +const sync = defineSync() + +// The same collections/mutations/commands as before, authored via the +// object-schema API. The schema VALUE is registered on the DO below. +const testSchema = sync.schema({ + collections: { + messages: sync.collection({ + pk: "id", + mutations: { + insert: { + authorize: ({ op }) => { + if (op.cols.body === "FORBIDDEN") throw new Error("forbidden body") + }, + execute: ({ op, sql }) => { + sql.exec("INSERT INTO messages(id, body) VALUES (?, ?)", op.cols.id, op.cols.body) + }, + }, + update: { + execute: ({ op, sql }) => { + sql.exec("UPDATE messages SET body = ? WHERE id = ?", op.cols.body, op.key) + }, + }, + delete: { + execute: ({ op, sql }) => { + sql.exec("DELETE FROM messages WHERE id = ?", op.key) + }, + }, + }, + }), + // A second collection on the same DO — exercises multiplexing over one WS. + files: sync.collection({ + pk: "id", + mutations: { + insert: { + execute: ({ op, sql }) => { + sql.exec("INSERT INTO files(id, name) VALUES (?, ?)", op.cols.id, op.cols.name) + }, + }, + // `files:delete` exercises afterCommit: the synchronous execute is the + // durable write; afterCommit is fire-and-forget async post-work (here it + // records a marker proving both `sql` and `env` reached the hook). + delete: { + execute: ({ op, sql }) => { + sql.exec("DELETE FROM files WHERE id = ?", op.key) + }, + afterCommit: async ({ op, sql, env }) => { + // A genuine async hop, to prove afterCommit awaits and runs off the + // request path. `_afterlog` is a plain side table (no CDC triggers). + await Promise.resolve() + if (op.key === "boom") throw new Error("afterCommit boom") + sql.exec("CREATE TABLE IF NOT EXISTS _afterlog (key TEXT PRIMARY KEY, tag TEXT)") + const hasEnv = (env as { SYNC_DO?: unknown }).SYNC_DO ? "has-env" : "no-env" + sql.exec("INSERT OR REPLACE INTO _afterlog(key, tag) VALUES (?, ?)", op.key, hasEnv) + }, + }, + }, + }), + }, + commands: { + echo: sync.command()(({ args }) => ({ echoed: args })), + // A SQL-WRITING command. Unlike `echo` (pure RPC), this mutates rows: + // its `DELETE` fires the same CDC triggers a mutation would, so the + // removed rows broadcast to other subscribers as ordinary `delete` + // deltas — AND it returns a result (the count) on `committed`. That + // pairing (bulk write + result) is exactly what a typed + // insert/update/delete mutation can't express, and why commands are + // the escape hatch for non-CRUD operations. + clearMessages: sync.command()(({ sql }) => { + const before = Array.from(sql.exec("SELECT count(*) AS c FROM messages"))[0]!.c as number + sql.exec("DELETE FROM messages") + return { deleted: before } + }), + boom: sync.command()(() => { + throw new Error("command boom") + }), + }, +}) + export class SyncTestDO extends SyncDurableObject { constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) @@ -27,75 +110,7 @@ export class SyncTestDO extends SyncDurableObject { // The author owns table creation; the framework wires sync after (ADR-0007). this.sql.exec(`CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, body TEXT)`) this.sql.exec(`CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, name TEXT)`) - this.registerSync( - new SyncRegistry() - .defineCollection({ table: "messages", pk: "id" }) - .defineMutation({ - collection: "messages", - type: "insert", - authorize: ({ op }) => { - if ((op.cols as unknown as MsgRow).body === "FORBIDDEN") throw new Error("forbidden body") - }, - execute: ({ op, sql }) => { - const c = op.cols as unknown as MsgRow - sql.exec("INSERT INTO messages(id, body) VALUES (?, ?)", c.id, c.body) - }, - }) - .defineMutation({ - collection: "messages", - type: "update", - execute: ({ op, sql }) => { - const c = op.cols as unknown as { body: string } - sql.exec("UPDATE messages SET body = ? WHERE id = ?", c.body, op.key as string) - }, - }) - .defineMutation({ - collection: "messages", - type: "delete", - execute: ({ op, sql }) => { - sql.exec("DELETE FROM messages WHERE id = ?", op.key as string) - }, - }) - // A second collection on the same DO — exercises multiplexing over one WS. - .defineCollection({ table: "files", pk: "id" }) - .defineMutation({ - collection: "files", - type: "insert", - execute: ({ op, sql }) => { - const c = op.cols as unknown as { id: string; name: string } - sql.exec("INSERT INTO files(id, name) VALUES (?, ?)", c.id, c.name) - }, - }) - // `files:delete` exercises afterCommit: the synchronous execute is the - // durable write; afterCommit is fire-and-forget async post-work (here it - // records a marker proving both `sql` and `env` reached the hook). - .defineMutation({ - collection: "files", - type: "delete", - execute: ({ op, sql }) => { - sql.exec("DELETE FROM files WHERE id = ?", op.key as string) - }, - afterCommit: async ({ op, sql, env }) => { - // A genuine async hop, to prove afterCommit awaits and runs off the - // request path. `_afterlog` is a plain side table (no CDC triggers). - await Promise.resolve() - if ((op.key as string) === "boom") throw new Error("afterCommit boom") - sql.exec("CREATE TABLE IF NOT EXISTS _afterlog (key TEXT PRIMARY KEY, tag TEXT)") - const hasEnv = (env as { SYNC_DO?: unknown }).SYNC_DO ? "has-env" : "no-env" - sql.exec("INSERT OR REPLACE INTO _afterlog(key, tag) VALUES (?, ?)", op.key as string, hasEnv) - }, - }) - .defineCommand({ - name: "echo", - execute: ({ args }) => ({ echoed: args }), - }) - .defineCommand({ - name: "boom", - execute: () => { - throw new Error("command boom") - }, - }), - ) + this.registerSync(testSchema) }) } diff --git a/tests/transport.test.ts b/tests/transport.test.ts index f92e5ef..a2a08de 100644 --- a/tests/transport.test.ts +++ b/tests/transport.test.ts @@ -10,8 +10,16 @@ import type { ClientFrame } from "../src/wire/frames.ts" // confirmation resolves, the single cursor advances on commit boundaries, a // rejected write surfaces as an error, and a command returns its result. -function connect(room: string): Promise { - const t = new WebSocketTransport({ +// Minimal slice of the test-worker schema Api: enough to name-check + infer the +// `echo` command's args/result through the transport's typed `sendCall`. +type TestApi = { + commands: { + echo: { __args?: { n: number }; __result?: { echoed: { n: number } } } + } +} + +function connect(room: string): Promise> { + const t = new WebSocketTransport({ url: `https://example.com/sync/${room}`, open: async () => { const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) @@ -101,8 +109,10 @@ describe("WebSocketTransport (M3 client)", () => { it("returns a command result via sendCall", async () => { const t = await connect("tr-call") - const res = await t.sendCall({ t: "call", txId: "c1", name: "echo", args: { n: 1 } }) - expect(res.result).toEqual({ echoed: { n: 1 } }) + // sendCall now takes (name, args), generates the txId internally, and + // resolves with the command's result directly (no `{ result }` wrapper). + const res = await t.sendCall("echo", { n: 1 }) + expect(res).toEqual({ echoed: { n: 1 } }) t.close() }) From 308e73802c279bbe70c3512daed7e0f2dbeadd1d Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Mon, 29 Jun 2026 17:11:05 +1000 Subject: [PATCH 02/10] refactor(examples): migrate chat/board/on-demand to defineSync Port the three existing examples off the removed SyncRegistry builder to the object-schema API (ADR-0014): each DO authors `defineSync().schema(...)` and exports `type Api = typeof schema`; the client types its transport `WebSocketTransport` and infers collection rows via `doCollectionOptions`. chat's "clear room" is now a typed `transport.call.clearRoom()`. Behavior unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- examples/board/src/client.tsx | 6 ++- examples/board/src/worker.ts | 85 ++++++++++++++++--------------- examples/chat/README.md | 31 +++++++++-- examples/chat/src/client.tsx | 36 +++++++++---- examples/chat/src/worker.ts | 77 +++++++++++++++++++--------- examples/on-demand/src/client.tsx | 12 ++--- examples/on-demand/src/worker.ts | 47 ++++++++++------- 7 files changed, 183 insertions(+), 111 deletions(-) diff --git a/examples/board/src/client.tsx b/examples/board/src/client.tsx index cf91231..ba6fcf4 100644 --- a/examples/board/src/client.tsx +++ b/examples/board/src/client.tsx @@ -16,6 +16,7 @@ import { useEffect, useRef, useState } from "react" import { createRoot } from "react-dom/client" import { ulid } from "ulid" import { doCollectionOptions, WebSocketTransport } from "../../../src/client/index.ts" +import type { BoardApi } from "./worker.ts" interface Task { id: string @@ -28,9 +29,10 @@ interface Task { const room = new URLSearchParams(location.search).get("room") ?? "demo" const qs = `room=${encodeURIComponent(room)}` const wsProto = location.protocol === "https:" ? "wss:" : "ws:" -const transport = new WebSocketTransport({ url: `${wsProto}//${location.host}/sync?${qs}` }) +const transport = new WebSocketTransport({ url: `${wsProto}//${location.host}/sync?${qs}` }) const tasks = createCollection( - doCollectionOptions({ transport, table: "tasks", getKey: (t) => t.id, syncMode: "on-demand" }), + // Row (Task) is inferred from BoardApi + the "tasks" table — no runtime schema. + doCollectionOptions({ transport, table: "tasks", getKey: (t) => t.id, syncMode: "on-demand" }), ) // A range index on the order column lets the live query page lazily via the // cursor instead of falling back to loading the whole subset. BTreeIndex suits diff --git a/examples/board/src/worker.ts b/examples/board/src/worker.ts index 3d3f4f8..afd482d 100644 --- a/examples/board/src/worker.ts +++ b/examples/board/src/worker.ts @@ -11,7 +11,7 @@ // `/bump` is a server-side load generator — it mutates random tasks (likely cold // ones the caller never loaded) and broadcasts, so the OTHER tab sees move-in. -import { SyncRegistry, SyncDurableObject } from "../../../src/server/index.ts" +import { defineSync, SyncDurableObject } from "../../../src/server/index.ts" interface Env { BOARD_DO: DurableObjectNamespace @@ -30,6 +30,49 @@ interface Task { const UPDATABLE = new Set(["title", "status", "votes", "updated_at"]) +const sync = defineSync() + +const boardSchema = sync.schema({ + collections: { + tasks: sync.collection({ + pk: "id", + mutations: { + insert: { + execute: ({ op, sql }) => { + const c = op.cols + sql.exec( + "INSERT INTO tasks(id, title, status, votes, updated_at) VALUES (?, ?, ?, ?, ?)", + c.id, + c.title, + c.status, + c.votes, + c.updated_at, + ) + }, + }, + // A vote/edit sends a getChanges() diff — any of title/status/votes + // plus the bumped updated_at. Build the SET from the present keys. + update: { + execute: ({ op, sql }) => { + const cols = op.cols as Record + const keys = Object.keys(cols).filter((k) => UPDATABLE.has(k)) + if (keys.length === 0) return + const set = keys.map((k) => `"${k}" = ?`).join(", ") + sql.exec(`UPDATE tasks SET ${set} WHERE id = ?`, ...keys.map((k) => cols[k]), op.key) + }, + }, + delete: { + execute: ({ op, sql }) => { + sql.exec("DELETE FROM tasks WHERE id = ?", op.key) + }, + }, + }, + }), + }, +}) + +export type BoardApi = typeof boardSchema + export class BoardDO extends SyncDurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) @@ -41,45 +84,7 @@ export class BoardDO extends SyncDurableObject { votes INTEGER NOT NULL, updated_at INTEGER NOT NULL )`) - this.registerSync( - new SyncRegistry() - .defineCollection({ table: "tasks", pk: "id" }) - .defineMutation({ - collection: "tasks", - type: "insert", - execute: ({ op, sql }) => { - const c = op.cols - sql.exec( - "INSERT INTO tasks(id, title, status, votes, updated_at) VALUES (?, ?, ?, ?, ?)", - c.id, - c.title, - c.status, - c.votes, - c.updated_at, - ) - }, - }) - .defineMutation({ - collection: "tasks", - type: "update", - // A vote/edit sends a getChanges() diff — any of title/status/votes - // plus the bumped updated_at. Build the SET from the present keys. - execute: ({ op, sql }) => { - const cols = op.cols as Record - const keys = Object.keys(cols).filter((k) => UPDATABLE.has(k)) - if (keys.length === 0) return - const set = keys.map((k) => `"${k}" = ?`).join(", ") - sql.exec(`UPDATE tasks SET ${set} WHERE id = ?`, ...keys.map((k) => cols[k]), op.key) - }, - }) - .defineMutation({ - collection: "tasks", - type: "delete", - execute: ({ op, sql }) => { - sql.exec("DELETE FROM tasks WHERE id = ?", op.key) - }, - }), - ) + this.registerSync(boardSchema) }) } diff --git a/examples/chat/README.md b/examples/chat/README.md index d41301a..436e98a 100644 --- a/examples/chat/README.md +++ b/examples/chat/README.md @@ -21,10 +21,31 @@ and watch messages sync live between them. Each tab gets a throwaway identity. - `npm run build:client` — bundle the React client to `public/client.js` (esbuild) - `npm run watch:client` — rebuild on change (run alongside `wrangler dev`) +## Commands vs mutations — the `clear room` button + +Sending a message is a **mutation**: a typed `insert` on the `messages` +collection, so it rides TanStack DB's optimistic path (the message shows +instantly, then confirms on the stream). + +"Clear the room" is not a single-row write, so it can't be an +insert/update/delete. It's a **command** (`sync.command` → +`transport.call.clearRoom()`): RPC that runs outside the optimistic path and returns a +result (here, the count it deleted). The thing worth seeing is that a command's +own SQL writes still flow through the CDC triggers — so the server-side `DELETE` +fans out to **every** connected tab as ordinary `delete` deltas, and the list +empties live for everyone. Open two tabs, fill one, hit `clear room`, and watch +both empty at once. + +That's the rule of thumb: typed row writes are mutations; anything else (bulk +ops, RPC, async external work, operations that return a value) is a command. + ## Shape -- `src/worker.ts` — `SessionDO` (one `messages` collection + an insert mutation - authorized to the connected user) and the upgrade router (`/sync` → DO, - everything else → static assets). -- `src/client.tsx` — one `WebSocketTransport` + a `messages` collection via - `doCollectionOptions`, rendered by `useLiveQuery`. +- `src/worker.ts` — `SessionDO` (one `messages` collection with an insert + mutation authorized to the connected user, plus a `clearRoom` command) and the + upgrade router (`/sync` → DO, everything else → static assets). The DO's + `defineSync` schema is exported as `ChatApi` for the client to type-only import. +- `src/client.tsx` — one `WebSocketTransport` + a `messages` collection + via `doCollectionOptions` (Row inferred from `ChatApi`), rendered by + `useLiveQuery`; `send` is an optimistic mutation, `clear room` calls the + command over `transport.call.clearRoom()`. diff --git a/examples/chat/src/client.tsx b/examples/chat/src/client.tsx index 9af69c7..aabd0ec 100644 --- a/examples/chat/src/client.tsx +++ b/examples/chat/src/client.tsx @@ -7,13 +7,7 @@ import { useState } from "react" import { createRoot } from "react-dom/client" import { ulid } from "ulid" import { doCollectionOptions, WebSocketTransport } from "../../../src/client/index.ts" - -interface Message { - id: string - author: string - content: string - created_at: number -} +import type { ChatApi } from "./worker.ts" // A throwaway identity persisted per browser, passed to the DO as ?user=. const user = @@ -25,13 +19,14 @@ const user = })() const wsProto = location.protocol === "https:" ? "wss:" : "ws:" -const transport = new WebSocketTransport({ +const transport = new WebSocketTransport({ url: `${wsProto}//${location.host}/sync?room=lobby&user=${encodeURIComponent(user)}`, }) -// One transport per DO, shared by every collection on it. +// One transport per DO, shared by every collection on it. The Row type is +// inferred from ChatApi + the table name — no runtime schema value needed. const messages = createCollection( - doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), + doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), ) function App(): JSX.Element { @@ -46,9 +41,28 @@ function App(): JSX.Element { setText("") } + // "Clear the room" is a COMMAND, not a mutation — it isn't a typed single-row + // write, so it goes over transport.sendCall (RPC), not the collection. Its + // server-side DELETE broadcasts delete deltas to every tab (this one included, + // via the live query), and resolves with the count it removed. + const clear = async (): Promise => { + const { deleted } = await transport.call.clearRoom() + console.log(`cleared ${deleted} message(s)`) + } + return (
-

tanstack-do-db chat

+
+

tanstack-do-db chat

+ +

you are {user} · open a second tab to watch live sync

diff --git a/examples/chat/src/worker.ts b/examples/chat/src/worker.ts index 4cf148d..815981a 100644 --- a/examples/chat/src/worker.ts +++ b/examples/chat/src/worker.ts @@ -4,7 +4,7 @@ // the real code with no build step for the lib. A published consumer would // instead `import { ... } from "tanstack-do-db-collection"`. -import { SyncRegistry, SyncDurableObject } from "../../../src/server/index.ts" +import { defineSync, SyncDurableObject } from "../../../src/server/index.ts" interface Env { SESSION_DO: DurableObjectNamespace @@ -22,6 +22,56 @@ interface Message { created_at: number } +// `defineSync` binds identity (Claims) and binding-env (Env) once; the helpers +// it returns flow those types into every handler ctx. +const sync = defineSync() + +const chatSchema = sync.schema({ + collections: { + // The collection KEY is the table name (ADR-0007: sole TEXT, client pk). + messages: sync.collection({ + pk: "id", + mutations: { + insert: { + // Only let a client write messages authored by itself. + authorize: ({ user, op }) => { + if (op.cols.author !== user.userId) { + throw new Error("author must match the connected user") + } + }, + execute: ({ op, sql }) => { + const c = op.cols + sql.exec( + "INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)", + c.id, + c.author, + c.content, + c.created_at, + ) + }, + }, + }, + }), + }, + commands: { + // A COMMAND (not a mutation): "clear the room" isn't a single typed row + // write, so it can't be insert/update/delete. A command is the escape hatch + // — it runs outside the optimistic path, can return a result, and (the part + // worth seeing) its own SQL writes still flow through the CDC triggers, so + // the DELETE fans out to every connected tab as ordinary delete deltas. The + // collection empties live for everyone, and the caller gets the count back + // on `committed`. + clearRoom: sync.command()(({ sql }) => { + const before = Array.from(sql.exec("SELECT count(*) AS c FROM messages"))[0]!.c as number + sql.exec("DELETE FROM messages") + return { deleted: before } + }), + }, +}) + +// The client type-only imports this to type its transport + collections. +export type ChatApi = typeof chatSchema + export class SessionDO extends SyncDurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) @@ -33,30 +83,7 @@ export class SessionDO extends SyncDurableObject { content TEXT NOT NULL, created_at INTEGER NOT NULL )`) - this.registerSync( - new SyncRegistry() - .defineCollection({ table: "messages", pk: "id" }) - .defineMutation({ - collection: "messages", - type: "insert", - // Only let a client write messages authored by itself. - authorize: ({ user, op }) => { - if (op.cols.author !== user.userId) { - throw new Error("author must match the connected user") - } - }, - execute: ({ op, sql }) => { - const c = op.cols - sql.exec( - "INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)", - c.id, - c.author, - c.content, - c.created_at, - ) - }, - }), - ) + this.registerSync(chatSchema) }) } diff --git a/examples/on-demand/src/client.tsx b/examples/on-demand/src/client.tsx index 3fb0ca4..685c7bc 100644 --- a/examples/on-demand/src/client.tsx +++ b/examples/on-demand/src/client.tsx @@ -8,23 +8,17 @@ import { useState } from "react" import { createRoot } from "react-dom/client" import { ulid } from "ulid" import { doCollectionOptions, WebSocketTransport } from "../../../src/client/index.ts" - -interface Item { - id: string - category: string - text: string - created_at: number -} +import type { ItemsApi } from "./worker.ts" const CATEGORIES = ["A", "B", "C"] const room = new URLSearchParams(location.search).get("room") ?? "demo" const wsProto = location.protocol === "https:" ? "wss:" : "ws:" -const transport = new WebSocketTransport({ +const transport = new WebSocketTransport({ url: `${wsProto}//${location.host}/sync?room=${encodeURIComponent(room)}`, }) const items = createCollection( - doCollectionOptions({ transport, table: "items", getKey: (i) => i.id, syncMode: "on-demand" }), + doCollectionOptions({ transport, table: "items", getKey: (i) => i.id, syncMode: "on-demand" }), ) // Mounting requests this category's subset; unmounting releases it. diff --git a/examples/on-demand/src/worker.ts b/examples/on-demand/src/worker.ts index 58fb808..6b6b3f5 100644 --- a/examples/on-demand/src/worker.ts +++ b/examples/on-demand/src/worker.ts @@ -2,7 +2,7 @@ // A GET /seed?room=… endpoint inserts fixed rows so subsets have pre-existing // data to load (demonstrating loadSubset fetching, not just live inserts). -import { SyncRegistry, SyncDurableObject } from "../../../src/server/index.ts" +import { defineSync, SyncDurableObject } from "../../../src/server/index.ts" interface Env { ITEMS_DO: DurableObjectNamespace @@ -25,6 +25,32 @@ const SEED: ReadonlyArray = [ ["c1", "C", "Cherry one"], ] +const sync = defineSync() + +const itemsSchema = sync.schema({ + collections: { + items: sync.collection({ + pk: "id", + mutations: { + insert: { + execute: ({ op, sql }) => { + const c = op.cols + sql.exec( + "INSERT INTO items(id, category, text, created_at) VALUES (?, ?, ?, ?)", + c.id, + c.category, + c.text, + c.created_at, + ) + }, + }, + }, + }), + }, +}) + +export type ItemsApi = typeof itemsSchema + export class ItemsDO extends SyncDurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) @@ -35,24 +61,7 @@ export class ItemsDO extends SyncDurableObject { text TEXT NOT NULL, created_at INTEGER NOT NULL )`) - this.registerSync( - new SyncRegistry() - .defineCollection({ table: "items", pk: "id" }) - .defineMutation({ - collection: "items", - type: "insert", - execute: ({ op, sql }) => { - const c = op.cols - sql.exec( - "INSERT INTO items(id, category, text, created_at) VALUES (?, ?, ?, ?)", - c.id, - c.category, - c.text, - c.created_at, - ) - }, - }), - ) + this.registerSync(itemsSchema) }) } From 691604376201022e0031bf90b9a2a95c994ce481 Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Mon, 29 Jun 2026 17:11:05 +1000 Subject: [PATCH 03/10] feat(examples): multi-DO sync showcase A new example with two separate Durable Objects (RoomDO + InboxDO) behind one Worker. The client opens one transport PER DO and exposes them through a React SyncProvider/useSync keyed by DO, so each DO's typed `transport.call` namespace stays disjoint. A cross-DO feed is merged client-side (the DO never joins, ADR-0001). Also indexes the examples. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- examples/README.md | 32 + examples/multi-do/.gitignore | 4 + examples/multi-do/README.md | 99 ++ examples/multi-do/package-lock.json | 2238 +++++++++++++++++++++++++ examples/multi-do/package.json | 26 + examples/multi-do/public/index.html | 12 + examples/multi-do/src/client.tsx | 198 +++ examples/multi-do/src/env.ts | 18 + examples/multi-do/src/inbox-schema.ts | 65 + examples/multi-do/src/room-schema.ts | 59 + examples/multi-do/src/worker.ts | 79 + examples/multi-do/tsconfig.json | 15 + examples/multi-do/wrangler.jsonc | 14 + 13 files changed, 2859 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/multi-do/.gitignore create mode 100644 examples/multi-do/README.md create mode 100644 examples/multi-do/package-lock.json create mode 100644 examples/multi-do/package.json create mode 100644 examples/multi-do/public/index.html create mode 100644 examples/multi-do/src/client.tsx create mode 100644 examples/multi-do/src/env.ts create mode 100644 examples/multi-do/src/inbox-schema.ts create mode 100644 examples/multi-do/src/room-schema.ts create mode 100644 examples/multi-do/src/worker.ts create mode 100644 examples/multi-do/tsconfig.json create mode 100644 examples/multi-do/wrangler.jsonc diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..78eb90e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,32 @@ +# Examples + +## [chat](./chat) + +A minimal multi-client chat — Worker + `SessionDO` + a React `useLiveQuery` +client. Showcases the whole stack end to end: optimistic **mutations**, live +cross-tab sync, reconnect, and a **command** (`clearRoom`) for the one action +that isn't a typed row write. + +## [on-demand](./on-demand) + +Categorised items where each category panel loads only when opened. Showcases +`syncMode: 'on-demand'` — the collection syncs only the subsets your live +queries request, via `loadSubset` / `unloadSubset` as panels mount and unmount. +Categories you never open are never synced. + +## [board](./board) + +A high-volume "task board": 5,000 tasks on **one** Durable Object. Showcases +windowed pagination at scale — a bounded window (top 50) with cursor `fetch` for +scroll-back; a **mutable** order key, so a bump arrives as **move-in / +move-out**; and **server-originated writes** (`runSyncedWrite`) via `/seed` and +`/bump`. It also surfaces the deferred bounded-window-under-churn limitation as a +live number. + +## [multi-do](./multi-do) + +Two **separate** Durable Objects (a room and an inbox) behind one Worker. +Showcases the multi-DO story: **one transport per DO**, a React +`SyncProvider` / `useSync` keyed by DO so each DO's typed `transport.call` +namespace stays disjoint (no command-name collisions), and a **cross-DO feed** +merged client-side (the DO never joins — ADR-0001). diff --git a/examples/multi-do/.gitignore b/examples/multi-do/.gitignore new file mode 100644 index 0000000..5077caa --- /dev/null +++ b/examples/multi-do/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +public/client.js +public/client.js.map +.wrangler/ diff --git a/examples/multi-do/README.md b/examples/multi-do/README.md new file mode 100644 index 0000000..8ad0cd6 --- /dev/null +++ b/examples/multi-do/README.md @@ -0,0 +1,99 @@ +# multi-do — tanstack-do-db-collection example + +The Cloudflare microservices story: **one app, two Durable Objects**, each its +own sync stream. `RoomDO` owns a chat room's `messages`; `InboxDO` owns a user's +`notifications`. They share no storage and no connection — the browser opens +**one transport per DO** and stitches the two collections back together +client-side. + +The example imports the library from source (`../../src`), so it always tracks +the current code. A published consumer would `import` from +`tanstack-do-db-collection` / `.../client` instead. + +## Run + +```sh +npm install +npm run dev # builds the client bundle, then `wrangler dev` +``` + +Open the printed URL (default http://localhost:8787). Post messages, hit “notify +me”, then `rooms.clearRoom()` / `inbox.markAllRead()` and watch the merged feed +update. Open a **second tab** to see the room sync live across clients. + +- `npm run build:client` — bundle the React client to `public/client.js` (esbuild) +- `npm run watch:client` — rebuild on change (run alongside `wrangler dev`) + +## Topology — one transport per DO + +There is **no muxed connection**. The Worker routes straight into each DO: + +``` +/rooms/:room/sync → env.ROOM_DO.get(idFromName(room)).fetch(req) +/inbox/:user/sync → env.INBOX_DO.get(idFromName(user)).fetch(req) +everything else → ASSETS (index.html + client.js) +``` + +A DO's sync stream is a single ordered WebSocket with one client cursor +(ADR-0002). That invariant is **per DO** — so two DOs means two transports, each +the ordered stream for exactly one DO: + +```ts +const roomTransport = new WebSocketTransport({ url: ".../rooms/lobby/sync?user=…" }) +const inboxTransport = new WebSocketTransport({ url: ".../inbox//sync?user=…" }) +``` + +Each transport is parameterized by **that DO's** `Api` (imported as a *type +only* — nothing server-side is bundled). So `roomTransport.call.*` exposes only +RoomDO's commands and `inboxTransport.call.*` only InboxDO's, fully typed. + +## Why commands are keyed by DO + +Command names are scoped to a DO, not global. `RoomDO` could name a command +`markAllRead` too, and it would be a *different* command on a *different* stream. +To keep that collision-safe on the client, the transports are exposed through a +`SyncProvider` **keyed by DO**, read with a `useSync` hook: + +```ts +const rooms = useSync("rooms") // WebSocketTransport +const inbox = useSync("inbox") // WebSocketTransport + +await rooms.call.clearRoom() // RoomApi — zero-arg command, returns { deleted } +await inbox.call.markAllRead() // InboxApi — returns { marked } +``` + +`rooms.call.*` and `inbox.call.*` are two disjoint, independently-typed +namespaces. There is no global command table to collide in: the DO you reached +*is* the namespace. + +## Cross-DO joins happen client-side + +The DO never joins, aggregates, or runs IVM (ADR-0001) — reads are client-side. +A “cross-DO view” is therefore assembled in the browser: two `useLiveQuery` +hooks (one per DO collection) merged into a single sorted timeline in render. +Both inputs stay live, so the merged feed updates whenever **either** DO emits a +delta. There is no server-side join across DOs — there couldn't be; they're +separate objects on separate streams. + +## Commands vs mutations + +- **Mutations** are typed single-row writes on a collection and ride the + optimistic path: posting a message (`messages.insert`), marking one + notification read (`notifications.update` with `{ read: 1 }`). +- **Commands** are everything else — bulk ops or anything returning a value: + `clearRoom` (deletes all, returns the count) and `markAllRead` (returns how + many it flipped). Their own SQL still flows through the CDC triggers, so the + bulk `DELETE`/`UPDATE` fans out to every connected tab as ordinary deltas. + +## Shape + +- `src/env.ts` — shared `Claims` + `Env` (both DO bindings); kept separate so the + schemas never import the worker. +- `src/room-schema.ts` — RoomDO's `defineSync` schema (`messages` insert + + `clearRoom` command); exports `RoomApi`. +- `src/inbox-schema.ts` — InboxDO's schema (`notifications` insert/update + + `markAllRead` command); exports `InboxApi`. +- `src/worker.ts` — `RoomDO` + `InboxDO` (each `registerSync`s its schema) and + the upgrade router. +- `src/client.tsx` — two typed transports behind a per-DO `SyncProvider`/ + `useSync`, two collections, and the merged cross-DO feed via `useLiveQuery`. diff --git a/examples/multi-do/package-lock.json b/examples/multi-do/package-lock.json new file mode 100644 index 0000000..feb3ab9 --- /dev/null +++ b/examples/multi-do/package-lock.json @@ -0,0 +1,2238 @@ +{ + "name": "tanstack-do-db-multi-do-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tanstack-do-db-multi-do-example", + "dependencies": { + "@msgpack/msgpack": "^3.0.0", + "@tanstack/db": "0.6.5", + "@tanstack/react-db": "0.1.83", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "ulid": "^2.3.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260518.1", + "@types/react": "^18", + "@types/react-dom": "^18", + "esbuild": "^0.24.0", + "typescript": "^5.7", + "wrangler": "^4" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz", + "integrity": "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260625.1.tgz", + "integrity": "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260625.1.tgz", + "integrity": "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260625.1.tgz", + "integrity": "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260625.1.tgz", + "integrity": "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260629.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260629.1.tgz", + "integrity": "sha512-5vq2ErIFVRSQ8Dcw5YOeEoBdZBudqBjHFKTWD3SBGiJR5hD1+/86aRUV/DTpEK9sXXCGOTGgpWBGlPSlW7djVg==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tanstack/db": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@tanstack/db/-/db-0.6.5.tgz", + "integrity": "sha512-gtCuAo4UtC9SR/kTMu5fVEff6qZ2R1FZi9X7MybtHKA6wve7RePifGG6qBI4OmMB+7juT5/+glNbnqZOrG0/pg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@tanstack/db-ivm": "0.1.18", + "@tanstack/pacer-lite": "^0.2.1" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@tanstack/db-ivm": { + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/@tanstack/db-ivm/-/db-ivm-0.1.18.tgz", + "integrity": "sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw==", + "license": "MIT", + "dependencies": { + "fractional-indexing": "^3.2.0", + "sorted-btree": "^1.8.1" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.2.2.tgz", + "integrity": "sha512-eQ1MyLKCHyXiH7NbdmB80W77OhiMgGBUb+qDx/8WMGbwg5Lf/NlfD0TfNYAqY77i8V3AxoDoYdICrQE5ADw4Yw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-db": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@tanstack/react-db/-/react-db-0.1.83.tgz", + "integrity": "sha512-LNV0C7OARazooT2hLTr5anXo6tbEyX2rHZQ0j9HZ/iNBI+Tx/y19o5Nd3ooyAYz5LEHJJxb8iM8ZTVB/diGnXw==", + "license": "MIT", + "dependencies": { + "@tanstack/db": "0.6.5", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/fractional-indexing": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.4.0.tgz", + "integrity": "sha512-8J3glhz2rrpKG6KmI7wmJo3zH1VjeOpN+vTJSw1fOyO+Viqq3zX6/5NGh6oaZB2qIAYdOYuu5Dz9xp4faOO0Pg==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/miniflare": { + "version": "4.20260625.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", + "integrity": "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260625.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sorted-btree": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sorted-btree/-/sorted-btree-1.8.1.tgz", + "integrity": "sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ulid": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.4.0.tgz", + "integrity": "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==", + "license": "MIT", + "bin": { + "ulid": "bin/cli.js" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/workerd": { + "version": "1.20260625.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260625.1.tgz", + "integrity": "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260625.1", + "@cloudflare/workerd-darwin-arm64": "1.20260625.1", + "@cloudflare/workerd-linux-64": "1.20260625.1", + "@cloudflare/workerd-linux-arm64": "1.20260625.1", + "@cloudflare/workerd-windows-64": "1.20260625.1" + } + }, + "node_modules/wrangler": { + "version": "4.105.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.105.0.tgz", + "integrity": "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260625.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260625.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260625.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/examples/multi-do/package.json b/examples/multi-do/package.json new file mode 100644 index 0000000..c9d61d8 --- /dev/null +++ b/examples/multi-do/package.json @@ -0,0 +1,26 @@ +{ + "name": "tanstack-do-db-multi-do-example", + "private": true, + "type": "module", + "scripts": { + "build:client": "esbuild src/client.tsx --bundle --outfile=public/client.js --format=esm --jsx=automatic", + "watch:client": "esbuild src/client.tsx --bundle --outfile=public/client.js --format=esm --jsx=automatic --watch", + "dev": "npm run build:client && wrangler dev" + }, + "dependencies": { + "@msgpack/msgpack": "^3.0.0", + "@tanstack/db": "0.6.5", + "@tanstack/react-db": "0.1.83", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "ulid": "^2.3.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260518.1", + "@types/react": "^18", + "@types/react-dom": "^18", + "esbuild": "^0.24.0", + "typescript": "^5.7", + "wrangler": "^4" + } +} diff --git a/examples/multi-do/public/index.html b/examples/multi-do/public/index.html new file mode 100644 index 0000000..d3f58bc --- /dev/null +++ b/examples/multi-do/public/index.html @@ -0,0 +1,12 @@ + + + + + + tanstack-do-db · multi-DO + + +
+ + + diff --git a/examples/multi-do/src/client.tsx b/examples/multi-do/src/client.tsx new file mode 100644 index 0000000..4b85ac2 --- /dev/null +++ b/examples/multi-do/src/client.tsx @@ -0,0 +1,198 @@ +// Multi-DO example — browser client. +// +// TWO Durable Objects ⇒ TWO transports. The client opens one WebSocket per DO +// (RoomDO at /rooms/:room/sync, InboxDO at /inbox/:user/sync) — there is no +// single muxed connection; each transport is the ordered stream for exactly one +// DO. Each Api is imported as a TYPE ONLY: nothing server-side is bundled (the +// `import type` is elided by esbuild), but Row/Args/Result are recovered +// structurally to type `transport.call.*` and the collections. + +import { createCollection } from "@tanstack/db" +import { useLiveQuery } from "@tanstack/react-db" +import { createContext, useContext, useState, type ReactNode } from "react" +import { createRoot } from "react-dom/client" +import { ulid } from "ulid" +import { doCollectionOptions, WebSocketTransport } from "../../../src/client/index.ts" +import type { InboxApi } from "./inbox-schema.ts" +import type { RoomApi } from "./room-schema.ts" + +// A throwaway identity persisted per browser, passed to both DOs as ?user=. +const user = + localStorage.getItem("multi-do-user") ?? + (() => { + const u = `user-${Math.random().toString(36).slice(2, 6)}` + localStorage.setItem("multi-do-user", u) + return u + })() + +const wsProto = location.protocol === "https:" ? "wss:" : "ws:" +const q = `user=${encodeURIComponent(user)}` + +// One transport PER DO. Each is parameterized by that DO's Api, so its typed +// `call` namespace only exposes that DO's commands. +const roomTransport = new WebSocketTransport({ + url: `${wsProto}//${location.host}/rooms/lobby/sync?${q}`, +}) +const inboxTransport = new WebSocketTransport({ + url: `${wsProto}//${location.host}/inbox/${encodeURIComponent(user)}/sync?${q}`, +}) + +// One collection per (DO, table). Row is inferred from the Api + table — no +// runtime schema value crosses the wire. +const messages = createCollection( + doCollectionOptions({ transport: roomTransport, table: "messages", getKey: (m) => m.id }), +) +const notifications = createCollection( + doCollectionOptions({ + transport: inboxTransport, + table: "notifications", + getKey: (n) => n.id, + }), +) + +// --- SyncProvider: transports keyed BY DO so command namespaces never collide. +// `useSync("rooms").call.*` and `useSync("inbox").call.*` are two disjoint, +// independently-typed namespaces — even if both DOs named a command the same. +interface SyncMap { + rooms: WebSocketTransport + inbox: WebSocketTransport +} + +const SyncContext = createContext(null) + +function SyncProvider({ value, children }: { value: SyncMap; children: ReactNode }): JSX.Element { + return {children} +} + +function useSync(key: K): SyncMap[K] { + const map = useContext(SyncContext) + if (!map) throw new Error("useSync must be used inside a ") + return map[key] +} + +function App(): JSX.Element { + const rooms = useSync("rooms") + const inbox = useSync("inbox") + + const { data: msgs } = useLiveQuery((qb) => qb.from({ m: messages }).orderBy(({ m }) => m.created_at, "asc")) + const { data: notes } = useLiveQuery((qb) => qb.from({ n: notifications }).orderBy(({ n }) => n.created_at, "asc")) + + const [text, setText] = useState("") + + // The DO never joins/aggregates (ADR-0001) — a cross-DO view is assembled + // HERE, client-side, by merging two live queries (one per DO) into a single + // timeline. Both inputs stay live, so the merged feed updates on either DO. + const feed: Array<{ id: string; at: number; from: "rooms" | "inbox"; label: string }> = [ + ...msgs.map((m) => ({ id: m.id, at: m.created_at, from: "rooms" as const, label: `${m.author}: ${m.content}` })), + ...notes.map((n) => ({ + id: n.id, + at: n.created_at, + from: "inbox" as const, + label: `[${n.read ? "read" : "unread"}] ${n.kind} — ${n.body}`, + })), + ].sort((a, b) => a.at - b.at) + + const post = (): void => { + const content = text.trim() + if (!content) return + // Optimistic typed insert on the RoomDO collection. + messages.insert({ id: ulid(), author: user, content, created_at: Date.now() }) + setText("") + } + + // Drop a notification into MY inbox DO (optimistic insert; read = 0). + const notify = (): void => { + notifications.insert({ + id: ulid(), + user, + kind: "ping", + body: `hello at ${new Date().toLocaleTimeString()}`, + read: 0, + created_at: Date.now(), + }) + } + + // Commands — one per DO, reached through that DO's transport. clearRoom takes + // no args (zero-arg call); both return a count on `committed`. + const clearRoom = async (): Promise => { + const { deleted } = await rooms.call.clearRoom() + console.log(`cleared ${deleted} message(s)`) + } + const markAllRead = async (): Promise => { + const { marked } = await inbox.call.markAllRead() + console.log(`marked ${marked} notification(s) read`) + } + + return ( +
+

tanstack-do-db · multi-DO

+

+ you are {user} · room lobby (RoomDO) + your inbox (InboxDO), two transports, one merged feed +

+ +
+ + + +
+ +
+ {feed.length === 0 ? ( +

Empty — post a message or hit “notify me”.

+ ) : ( + feed.map((e) => ( +
+ {e.from} · {e.label} +
+ )) + )} +
+ +
{ + e.preventDefault() + post() + }} + style={{ display: "flex", gap: 8, marginTop: 8 }} + > + setText(e.target.value)} + placeholder="message the room…" + style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }} + /> + +
+
+ ) +} + +const btn = { padding: "4px 10px", borderRadius: 6, border: "1px solid #ccc", background: "#fff" } as const + +createRoot(document.getElementById("root")!).render( + + + , +) diff --git a/examples/multi-do/src/env.ts b/examples/multi-do/src/env.ts new file mode 100644 index 0000000..a1d86e8 --- /dev/null +++ b/examples/multi-do/src/env.ts @@ -0,0 +1,18 @@ +// Shared binding env + identity for both DOs. +// +// The schema modules (`room-schema.ts`, `inbox-schema.ts`) bind these into +// `defineSync()`, and `worker.ts` types its DO subclasses + handler +// against the same `Env`. Kept in its own module so the schemas never have to +// import the worker (which would be a cycle: the worker imports the schemas). + +export interface Claims { + /** The connected user. The example trusts a `?user=` query param; a real app + * verifies a token at the Worker and forges a claims header (see README). */ + userId: string +} + +export interface Env { + ROOM_DO: DurableObjectNamespace + INBOX_DO: DurableObjectNamespace + ASSETS: { fetch: (req: Request) => Promise } +} diff --git a/examples/multi-do/src/inbox-schema.ts b/examples/multi-do/src/inbox-schema.ts new file mode 100644 index 0000000..3cdb103 --- /dev/null +++ b/examples/multi-do/src/inbox-schema.ts @@ -0,0 +1,65 @@ +// InboxDO sync surface — a user's notifications. +// +// A SECOND, independent DO with its OWN schema/Api. Note the command name +// `markAllRead` could perfectly well coexist with RoomDO's `clearRoom` — they +// live on different transports, so even identically-named commands on the two +// DOs never collide (the client keys them by DO; see client.tsx / README). + +import { defineSync } from "../../../src/server/index.ts" +import type { Claims, Env } from "./env.ts" + +export interface Notification { + id: string + user: string + kind: string + body: string + /** SQLite has no boolean: 0 = unread, 1 = read. */ + read: number + created_at: number +} + +const sync = defineSync() + +export const inboxSchema = sync.schema({ + collections: { + notifications: sync.collection({ + pk: "id", + mutations: { + insert: { + execute: ({ op, sql }) => { + const c = op.cols + sql.exec( + "INSERT INTO notifications(id, user, kind, body, read, created_at) VALUES (?, ?, ?, ?, ?, ?)", + c.id, + c.user, + c.kind, + c.body, + c.read, + c.created_at, + ) + }, + }, + // Marking one notification read IS a typed single-row write — a partial + // `update` patch ({ read: 1 }) — so it's a mutation, not a command. + update: { + execute: ({ op, sql }) => { + if (op.cols.read !== undefined) { + sql.exec("UPDATE notifications SET read = ? WHERE id = ?", op.cols.read, op.key) + } + }, + }, + }, + }), + }, + commands: { + // Bulk "mark everything read" returns a count — a command (returns a value, + // touches many rows). Its UPDATE fans out as update deltas to every tab. + markAllRead: sync.command()(({ sql }) => { + const unread = Array.from(sql.exec("SELECT count(*) AS c FROM notifications WHERE read = 0"))[0]!.c as number + sql.exec("UPDATE notifications SET read = 1 WHERE read = 0") + return { marked: unread } + }), + }, +}) + +export type InboxApi = typeof inboxSchema diff --git a/examples/multi-do/src/room-schema.ts b/examples/multi-do/src/room-schema.ts new file mode 100644 index 0000000..c6793a9 --- /dev/null +++ b/examples/multi-do/src/room-schema.ts @@ -0,0 +1,59 @@ +// RoomDO sync surface — a chat room's messages. +// +// `defineSync()` binds identity + env once; the schema VALUE it +// produces is registered by the DO (`registerSync`) AND imported *as a type +// only* by the client to drive `transport.call.*` and `doCollectionOptions`. +// Export the type as `RoomApi` — that's this DO's whole client contract. + +import { defineSync } from "../../../src/server/index.ts" +import type { Claims, Env } from "./env.ts" + +export interface Message { + id: string + author: string + content: string + created_at: number +} + +const sync = defineSync() + +export const roomSchema = sync.schema({ + collections: { + // KEY "messages" === the DB table name (interpolated into trigger DDL). + messages: sync.collection({ + pk: "id", + mutations: { + insert: { + // Only let a client write messages authored by itself. + authorize: ({ user, op }) => { + if (op.cols.author !== user.userId) { + throw new Error("author must match the connected user") + } + }, + execute: ({ op, sql }) => { + const c = op.cols + sql.exec( + "INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)", + c.id, + c.author, + c.content, + c.created_at, + ) + }, + }, + }, + }), + }, + commands: { + // A COMMAND, not a mutation: "clear the room" isn't a typed single-row write. + // Its own DELETE still flows through the CDC triggers, so it fans out to + // every connected tab as delete deltas, and the caller gets the count back. + clearRoom: sync.command()(({ sql }) => { + const before = Array.from(sql.exec("SELECT count(*) AS c FROM messages"))[0]!.c as number + sql.exec("DELETE FROM messages") + return { deleted: before } + }), + }, +}) + +export type RoomApi = typeof roomSchema diff --git a/examples/multi-do/src/worker.ts b/examples/multi-do/src/worker.ts new file mode 100644 index 0000000..a5b7721 --- /dev/null +++ b/examples/multi-do/src/worker.ts @@ -0,0 +1,79 @@ +// Multi-DO example — Worker + TWO Durable Objects (the Cloudflare +// microservices story). RoomDO owns a chat room's messages; InboxDO owns a +// user's notifications. They are entirely separate DOs with separate storage, +// schemas, and sync streams; the client opens ONE transport PER DO. +// +// Imports the library straight from source (../../../src) so the example tracks +// the real code with no build step for the lib. A published consumer would +// instead `import { ... } from "tanstack-do-db-collection"`. + +import { SyncDurableObject } from "../../../src/server/index.ts" +import type { Claims, Env } from "./env.ts" +import { inboxSchema } from "./inbox-schema.ts" +import { roomSchema } from "./room-schema.ts" + +export class RoomDO extends SyncDurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env) + ctx.blockConcurrencyWhile(async () => { + // You own your schema (ADR-0007); the framework wires sync after. + this.sql.exec(`CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + author TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL + )`) + this.registerSync(roomSchema) + }) + } + + // The example trusts a `user` query param for identity. A real app verifies a + // token at the Worker and forges a claims header (see the README). + protected override parseAttachment(req: Request): Claims { + return { userId: new URL(req.url).searchParams.get("user") ?? "anon" } + } +} + +export class InboxDO extends SyncDurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env) + ctx.blockConcurrencyWhile(async () => { + this.sql.exec(`CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user TEXT NOT NULL, + kind TEXT NOT NULL, + body TEXT NOT NULL, + read INTEGER NOT NULL, + created_at INTEGER NOT NULL + )`) + this.registerSync(inboxSchema) + }) + } + + protected override parseAttachment(req: Request): Claims { + return { userId: new URL(req.url).searchParams.get("user") ?? "anon" } + } +} + +export default { + async fetch(req: Request, env: Env): Promise { + const url = new URL(req.url) + + // /rooms/:room/sync → the RoomDO instance named by :room. Each room is its + // own DO (idFromName), so two rooms never share a stream. + const room = url.pathname.match(/^\/rooms\/([^/]+)\/sync$/) + if (room) { + const name = decodeURIComponent(room[1]!) // capture group is mandatory when the match succeeds + return env.ROOM_DO.get(env.ROOM_DO.idFromName(name)).fetch(req) + } + + // /inbox/:user/sync → the InboxDO instance named by :user — a per-user DO. + const inbox = url.pathname.match(/^\/inbox\/([^/]+)\/sync$/) + if (inbox) { + const name = decodeURIComponent(inbox[1]!) // capture group is mandatory when the match succeeds + return env.INBOX_DO.get(env.INBOX_DO.idFromName(name)).fetch(req) + } + + return env.ASSETS.fetch(req) // index.html + client.js + }, +} satisfies ExportedHandler diff --git a/examples/multi-do/tsconfig.json b/examples/multi-do/tsconfig.json new file mode 100644 index 0000000..ca4f0ce --- /dev/null +++ b/examples/multi-do/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*"] +} diff --git a/examples/multi-do/wrangler.jsonc b/examples/multi-do/wrangler.jsonc new file mode 100644 index 0000000..7123336 --- /dev/null +++ b/examples/multi-do/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "name": "tanstack-do-db-multi-do", + "main": "src/worker.ts", + "compatibility_date": "2026-03-10", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "ROOM_DO", "class_name": "RoomDO" }, + { "name": "INBOX_DO", "class_name": "InboxDO" } + ] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RoomDO", "InboxDO"] }], + "assets": { "directory": "./public", "binding": "ASSETS" } +} From 3154e400b8697e00f0963eb913de46c58528e805 Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Mon, 29 Jun 2026 17:39:05 +1000 Subject: [PATCH 04/10] refactor(registry): align ADR-0014 with insert.schema, drop OpFor, pin types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-0014: rewrite D3 (and the consequences bullet) to the landed design — the row schema lives on `mutations.insert.schema` (infers Row + validates the full row), `update.schema` validates the partial patch, command schema validates args, delete is unvalidated. Documents the gate-not-parser rule. Drops the stale positional `collection(zMessage, def)` form and the old "update/delete unvalidated" framing. The ADR is evergreen — it records the decision; this base branch wires the runtime in the stacked follow-up. - Drop the vestigial `OpFor` type (and its re-export). The authoring API uses `InsertOp`/`UpdateOp`/`DeleteOp` directly; nothing referenced `OpFor`. - registry-types.ts: pin the new typing — Row inferred from `insert.schema`, `update.schema` typed `StandardSchemaV1>`, and the trio stays closed with a schema present. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- docs/adr/0014-object-sync-schema.md | 55 +++++++++++++++++++---------- src/server/index.ts | 1 - src/server/registry.ts | 13 ++----- tests/registry-types.ts | 53 ++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 30 deletions(-) diff --git a/docs/adr/0014-object-sync-schema.md b/docs/adr/0014-object-sync-schema.md index 0e76087..8e672a2 100644 --- a/docs/adr/0014-object-sync-schema.md +++ b/docs/adr/0014-object-sync-schema.md @@ -101,26 +101,43 @@ The two shapes stay distinct at runtime exactly as ADR-0012 D3 found: a command' helpers, rather than ADR-0010's sibling `defineX` methods, makes that divergence structural instead of incidental. -### D3: Row co-located on the collection; an optional Standard Schema slot gives runtime validation + inference +### D3: The row schema lives on the insert mutation; it infers the row and validates writes -The row type lives on the collection two ways. Type-only — +A collection's row type lives on the collection two ways. Type-only — `sync.collection({ pk, mutations })` — recovers ADR-0010's precise `op.cols` with no runtime cost (`pk` is checked against `keyof Row & string`). -Or **schema-first** — `sync.collection(zMessage, { pk, mutations })` — where `Row` -is *inferred from* a [Standard Schema](https://standardschema.dev/) value and the -schema's `~standard.validate` runs at **runtime** inside the compiled `authorize`, -before the author's `authorize`/`execute`, throwing (fail-loud, rejecting the -frame) on issues. +Or **from a [Standard Schema](https://standardschema.dev/) on the insert +mutation** — `sync.collection({ pk, mutations: { insert: { schema: zMessage } } })` +— where `Row` is inferred from `insert.schema` and flows to `pk`, to `update`'s +`Partial`, and to the client. The "default" row schema and the insert +validator are the same thing, so the insert mutation is its one home. There is no +separate positional schema argument. + +Each op may carry its own schema, and when present it is checked at runtime inside +the compiled `authorize`, before the author's `authorize`/`execute`, throwing +(fail-loud, rejecting the frame) on issues: + +- `insert.schema` validates the full-row `cols`. +- `update.schema` validates the partial patch. The author supplies a partial + schema (e.g. `Message.partial()`), because an update carries a top-level partial, + not a full row, and a full-row schema would reject every valid partial. +- a command's schema validates its `args`. +- a `delete` has no schema. It carries only the key, the wire layer already checks + the key is a non-empty string (ADR-0012), and the pk was validated at insert. This reverses ADR-0010's B3 rejection narrowly and deliberately. ADR-0010 rejected a schema slot because it bought no *injection* safety (parameterised binding -already covers that) at a per-mutation hot-path cost. That still holds — so -validation is **opt-in** (no schema → no validator runs) and **scoped to where a -full row exists**: an `insert`'s `cols` and a command's `args` are validated; -`update` partials and `delete`s are **not** (no complete value to soundly check). -The slot's primary payoff is *inference + a typed client contract*, with runtime -validation as the opt-in bonus — not blanket hot-path validation. The interface is -the dependency-free `~standard` shape (`StandardSchemaV1`, exported); **no validator +already covers that) at a per-mutation hot-path cost. That still holds, so +validation is **opt-in**: no schema means no validator runs. The slot's primary +payoff is inference and a typed client contract, with runtime validation as the +opt-in bonus. + +It is a validation **gate, not a parser**: the handler receives the original wire +value, never the schema's parsed output, so a schema must not rely on +transforms/defaults/coercion (input must equal output). Rewriting a row the client +already applied optimistically would manufacture divergence, and a pk rewrite would +break optimistic-id == confirmed-id (ADR-0001 D9). The interface is the +dependency-free `~standard` shape (`StandardSchemaV1`, exported); **no validator runtime is imported** — zod/valibot/arktype all satisfy it, and an author who wants none pays nothing. @@ -180,10 +197,12 @@ mut/call handler, `runSyncedWrite` is still the path (ADR-0006). - **Closed mutations, open commands** — the shape now encodes the rule: if a write isn't one-row insert/update/delete it is a command, and the compiler says so (excess-property error on a 4th mutation key). -- **Opt-in validation, scoped.** No schema → zero validator on the hot path - (ADR-0010's objection preserved). Schema present → insert `cols` and command - `args` validated and inferred; update/delete deliberately unvalidated (no full - row). Dependency-free via Standard Schema; no zod runtime pulled in. +- **Opt-in validation, scoped, gate-not-parser.** No schema → zero validator on + the hot path (ADR-0010's objection preserved). Schema present → `insert.schema` + validates the full row and infers `Row`, `update.schema` validates the partial + patch, a command schema validates `args`; a `delete` is unvalidated (no cols, pk + validated at insert). It validates but does not transform — the original value + flows to handlers. Dependency-free via Standard Schema; no zod runtime pulled in. - **`authorize` divergence is now structural** (ADR-0012 D3): mutation-authorize throws stay user-facing, command-authorize throws are sanitized — the two helpers make that explicit rather than a shared-code accident. diff --git a/src/server/index.ts b/src/server/index.ts index 15c13f1..317ebe8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -21,7 +21,6 @@ export type { InsertOp, MutationCtx, Mutations, - OpFor, RuntimeCommandDef, RuntimeMutationDef, StandardSchemaV1, diff --git a/src/server/registry.ts b/src/server/registry.ts index 1132c25..e611e45 100644 --- a/src/server/registry.ts +++ b/src/server/registry.ts @@ -66,16 +66,9 @@ export interface CollectionDef { pk: string } -/** Per-op shape of `op`, discriminated by `type`: an insert carries the full - * row, an update a top-level partial patch (ADR-0002 C6), a delete only the - * key. Kept as a public alias for back-compat; the authoring API below uses - * the explicit `InsertOp`/`UpdateOp`/`DeleteOp` triple. */ -export type OpFor = T extends "insert" - ? InsertOp - : T extends "update" - ? UpdateOp - : DeleteOp - +/** Per-op shape of `op`, discriminated by `type`: an insert carries the full row + * (ADR-0001 D19), an update a top-level partial patch (ADR-0002 C6), a delete + * only the key. */ export type InsertOp = { type: "insert"; key: string; cols: Row } export type UpdateOp = { type: "update"; key: string; cols: Partial } export type DeleteOp = { type: "delete"; key: string; cols?: undefined } diff --git a/tests/registry-types.ts b/tests/registry-types.ts index b77b67a..cd02900 100644 --- a/tests/registry-types.ts +++ b/tests/registry-types.ts @@ -5,7 +5,7 @@ // fails to compile is a red type-test. This pins the author-facing typing the // same way a runtime test pins behaviour. -import { defineSync } from "../src/server/registry.ts" +import { defineSync, type StandardSchemaV1 } from "../src/server/registry.ts" interface Claims { userId: string @@ -85,3 +85,54 @@ const schema = sync.schema({ commands: { echo }, }) void schema + +// --- schema-on-insert: Row is inferred from `mutations.insert.schema` --- +declare function schemaOf(): StandardSchemaV1 + +// No `` generic: Row is inferred from insert.schema and flows to pk, update, +// and each op's cols. +const inferred = sync.collection({ + pk: "id", + mutations: { + insert: { + schema: schemaOf(), + execute: ({ op }) => { + const a: string = op.cols.author // op.cols is Message + void a + }, + }, + update: { + schema: schemaOf>(), + execute: ({ op }) => { + const a: string | undefined = op.cols.author // op.cols is Partial + void a + }, + }, + }, +}) +void inferred + +// @ts-expect-error pk must be a real column of the inferred Row +sync.collection({ pk: "nope", mutations: { insert: { schema: schemaOf(), execute: () => {} } } }) + +sync.collection({ + pk: "id", + mutations: { + insert: { schema: schemaOf(), execute: () => {} }, + update: { + // @ts-expect-error update.schema must match Partial; content: number conflicts with string + schema: schemaOf<{ content: number }>(), + execute: () => {}, + }, + }, +}) + +// The trio stays closed when a schema is present. +sync.collection({ + pk: "id", + mutations: { + insert: { schema: schemaOf(), execute: () => {} }, + // @ts-expect-error `archive` is not a member of the insert/update/delete trio + archive: { execute: () => {} }, + }, +}) From 834f90806e8519a803b33cf32eaa602bcd2ba627 Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Mon, 29 Jun 2026 18:07:25 +1000 Subject: [PATCH 05/10] docs: add recipes for commands, on-demand loading, server writes, and types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four task-oriented guides under recipes/: - commands-vs-mutations.md — when each fits, the optimistic vs RPC split. - on-demand-and-windows.md — syncMode: "on-demand", per-query subsets, and windowed pagination with useLiveInfiniteQuery. - server-originated-writes.md — runSyncedWrite for webhooks/jobs/seeds, and afterCommit for post-commit work. - end-to-end-types.md — `type Api = typeof schema` as the shared contract. The Standard Schema validation recipe is intentionally not here; it lands with the runtime-validation follow-up that 3154e40 set up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- recipes/commands-vs-mutations.md | 100 ++++++++++++++++++++++++++++ recipes/end-to-end-types.md | 64 ++++++++++++++++++ recipes/on-demand-and-windows.md | 92 +++++++++++++++++++++++++ recipes/server-originated-writes.md | 68 +++++++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 recipes/commands-vs-mutations.md create mode 100644 recipes/end-to-end-types.md create mode 100644 recipes/on-demand-and-windows.md create mode 100644 recipes/server-originated-writes.md diff --git a/recipes/commands-vs-mutations.md b/recipes/commands-vs-mutations.md new file mode 100644 index 0000000..66aab18 --- /dev/null +++ b/recipes/commands-vs-mutations.md @@ -0,0 +1,100 @@ +# When to use a mutation and when to use a command + +A mutation is a typed write to one collection. It is an insert, an update, or a +delete. A command is a named call for anything that is not one of those three +writes. Use a mutation for ordinary row writes, and use a command for everything +else. + +## Mutations + +On the client you write through the collection, and the change shows right away, +before the server confirms it. This is an optimistic update, and the framework +rolls it back if the server rejects it. + +```ts +messages.insert({ id: ulid(), author: me, content, created_at: Date.now() }) +messages.update(id, (m) => { m.content = "edited" }) +messages.delete(id) +``` + +On the server you define the matching handlers. A collection has at most an +insert, an update, and a delete. Each `execute` runs inside a transaction, so it +must be synchronous. + +```ts +messages: sync.collection({ + pk: "id", + mutations: { + insert: { + authorize: ({ user, op }) => { + if (op.cols.author !== user.userId) throw new Error("not your message") + }, + execute: ({ op, sql }) => sql.exec("INSERT INTO messages(...) VALUES (...)", op.cols.id /* ... */), + }, + update: { execute: ({ op, sql }) => {/* op.cols is a partial patch */} }, + delete: { execute: ({ op, sql }) => sql.exec("DELETE FROM messages WHERE id = ?", op.key) }, + }, +}), +``` + +## Commands + +A command has any name you choose. You call it on the transport and await its +result. + +```ts +// client +const { deleted } = await transport.call.clearRoom() +``` + +```ts +// server +commands: { + clearRoom: sync.command()(({ sql }) => { + const before = count(sql) + sql.exec("DELETE FROM messages") + return { deleted: before } + }), +}, +``` + +A command's `execute` runs outside a transaction, so it can be async. It can do +work a mutation cannot, such as calling another service, and it returns a value +to the caller. A command can also write rows, and those writes broadcast to +other clients as ordinary changes, the same as a mutation's writes. + +## How they differ + +- A mutation is an insert, update, or delete on a collection. A command has any + name. +- A mutation is optimistic, so the change shows on the client at once and rolls + back if the server rejects it. A command is not optimistic, so you await its + result. +- A mutation's `execute` is synchronous and runs in a transaction. A command's + `execute` can be async and runs outside a transaction. +- A mutation returns nothing to the caller. A command returns a result. +- Both can write rows that broadcast to other clients. + +## When a command is the right choice + +Use a command when the work is not a single typed row write, e.g. deleting many +rows at once, returning a computed value, or calling an external service. "Clear +the room" in the chat example is a command, because it deletes every message and +returns the count. + +## Notes + +- A mutation's `authorize` denies a write by throwing, and the client receives + that error message. A command's `authorize` can also throw to deny, but the + client receives a generic rejection and the server logs the message + (ADR-0012). If you need to tell the user why a command was refused, return that + in the result instead of throwing. +- You can type a command's args, either with a generic + (`sync.command<{ before: number }>()(fn)`) or from a schema + (`sync.command(zArgs, fn)`). + +## See also + +- ADR-0014 describes the schema and why the mutation set is fixed. +- `examples/chat` has an insert mutation and the `clearRoom` command. +- `examples/multi-do` calls a command on each of two Durable Objects. diff --git a/recipes/end-to-end-types.md b/recipes/end-to-end-types.md new file mode 100644 index 0000000..603093c --- /dev/null +++ b/recipes/end-to-end-types.md @@ -0,0 +1,64 @@ +# Share types between the server and the client + +The schema you define on the server is also the client's contract. Export its +type, import it on the client, and the transport, the commands, and the +collections are all typed from it. No server code ships to the browser. Only the +type is used. + +## Recipe + +```ts +// server, e.g. src/schema.ts +const sync = defineSync() +export const schema = sync.schema({ + collections: { messages: sync.collection({ pk: "id", mutations: { /* ... */ } }) }, + commands: { clearRoom: sync.command()(({ sql }) => ({ deleted: clearAll(sql) })) }, +}) +export type Api = typeof schema +``` + +```ts +// client +import type { Api } from "../server/schema" // type only; no server code is bundled + +const transport = new WebSocketTransport({ url }) + +// the command name, its args, and its result are all checked against the schema +const { deleted } = await transport.call.clearRoom() + +// the row type is inferred from the schema and the table name +const messages = createCollection( + doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), +) +``` + +## How it works + +The schema value carries the row type of each collection, and the arguments and +result of each command, in its type. `typeof schema` recovers them on the client. +The import is a type-only import, so the bundler removes it and no server code +reaches the browser. + +## Two ways to type a collection's row + +Pick whichever you prefer. + +- Pass the row type as a generic: `sync.collection({ pk: "id", mutations })`. +- Infer it from a schema on the insert mutation: `sync.collection({ pk: "id", + mutations: { insert: { schema: Message, execute } } })`. On this branch the + schema only infers the type. A runtime check against the schema is a separate + follow-up. + +## Notes + +- Keep the schema in a module that both sides import. The Durable Object imports + the value to register it, and the client imports the type. Importing the type + from the worker file works in these examples, but a published app should keep + the schema in its own module so the browser bundle never pulls in server code. +- A schema with no commands has no callable commands, so `transport.call.anything` + is a type error, not a silent call that does nothing. + +## See also + +- ADR-0014 describes the schema as the shared contract. +- `examples/chat` types its transport and collection from `ChatApi`. diff --git a/recipes/on-demand-and-windows.md b/recipes/on-demand-and-windows.md new file mode 100644 index 0000000..0e9dea1 --- /dev/null +++ b/recipes/on-demand-and-windows.md @@ -0,0 +1,92 @@ +# Load only part of a collection on demand + +By default a collection syncs every row up front. This is the eager mode. With +`syncMode: "on-demand"`, the collection syncs only the rows that a live query +asks for. Use it when a collection is large and a client needs only part of it at +a time. + +There are two common shapes. In the first, each query loads the rows that match +its filter. In the second, a bounded list grows as the user scrolls. + +## Load a subset per query + +Create the collection in on-demand mode. Each live query then loads its rows when +it mounts and releases them when it unmounts. + +```ts +const items = createCollection( + doCollectionOptions({ + transport, + table: "items", + getKey: (i) => i.id, + syncMode: "on-demand", + }), +) + +function CategoryPanel({ category }: { category: string }) { + // Mounting this query loads the matching rows. Unmounting releases them. + const { data } = useLiveQuery((q) => + q.from({ i: items }).where(({ i }) => eq(i.category, category)).orderBy(({ i }) => i.created_at, "asc"), + ) + return
    {data.map((i) =>
  • {i.text}
  • )}
+} +``` + +A category you never open is never synced. See `examples/on-demand` for the full +app. + +## Grow a window as you scroll + +For a long ordered list, use `useLiveInfiniteQuery`. It keeps a bounded window +and loads the next page when you call `fetchNextPage`. Add a range index on the +order column so the query can fetch one page at a time instead of loading every +matching row. A range index is a sorted index that supports fetching rows by a +range of values. + +```ts +const tasks = createCollection( + doCollectionOptions({ + transport, + table: "tasks", + getKey: (t) => t.id, + syncMode: "on-demand", + }), +) +tasks.createIndex((t) => t.updated_at, { indexType: BTreeIndex }) + +const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => q.from({ t: tasks }).orderBy(({ t }) => t.updated_at, "desc"), + { pageSize: 50 }, +) +``` + +On join the client loads about one page, even when the table holds thousands of +rows. Scrolling loads older pages. See `examples/board` for the full app. + +## How it works + +Each distinct filter is one subscription on the Durable Object. A subscription is +shared, so two queries with the same filter use one, and the Durable Object +releases it when the last query using it unmounts. The Durable Object returns a +bounded page for each request and never the whole table. The client applies +ordering and limits over the rows it has loaded. + +## Notes + +- A write can land outside every loaded subset, e.g. you insert a row in a + category that no open panel is showing. The write is still confirmed, and the + client retires its optimistic copy with a follow-up sync commit, so the row + does not stay unconfirmed. +- Under heavy change the loaded row count can grow past the visible window. A row + that moves into the window is added and is not removed later. Keeping the + loaded set as small as the window is a known limitation, and `examples/board` + shows the gap as a live number. +- Eager mode with a static `where` also filters, but it loads every matching row + up front. Use on-demand when even the filtered set is too large to load at + once. + +## See also + +- `examples/on-demand` loads one category subset at a time. +- `examples/board` is a windowed list over thousands of rows. +- ADR-0002 and ADR-0005 cover the subset and page-fetch design. diff --git a/recipes/server-originated-writes.md b/recipes/server-originated-writes.md new file mode 100644 index 0000000..0c0f9ed --- /dev/null +++ b/recipes/server-originated-writes.md @@ -0,0 +1,68 @@ +# Write to a collection from the server + +Most writes come from a client mutation. Sometimes the server itself needs to +write a row, e.g. a webhook updates a record, a scheduled job inserts data, or +you seed a table. Wrap those writes in `this.runSyncedWrite` so connected clients +see them. + +## Recipe + +```ts +export class RoomDO extends SyncDurableObject { + override async fetch(req: Request): Promise { + if (new URL(req.url).pathname === "/seed") { + // Runs in a transaction and broadcasts the change to connected clients. + this.runSyncedWrite((sql) => { + sql.exec( + "INSERT OR IGNORE INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)", + ulid(), "system", "welcome", Date.now(), + ) + }) + return new Response("seeded") + } + return super.fetch(req) + } +} +``` + +## Why runSyncedWrite and not a plain sql.exec + +A plain `sql.exec` writes the row and records the change, but the change is sent +to clients only on the next mutation or command. `runSyncedWrite` sends the +change now, so connected clients update right away (ADR-0006). + +## Run work after a mutation commits + +When you want work to run after a mutation has committed, e.g. delete a file in +R2 or add a job to a queue, put it in the mutation's `afterCommit`. It runs after +the client has its confirmation, it can be async, and the framework keeps the +Durable Object awake until it finishes. + +```ts +delete: { + execute: ({ op, sql }) => sql.exec("DELETE FROM files WHERE id = ?", op.key), + afterCommit: async ({ op, env }) => { await env.BUCKET.delete(op.key) }, +}, +``` + +A thrown error in `afterCommit` is logged and dropped, and there is no retry, so +make the work idempotent. Write it so that a later run can finish whatever an +interrupted run left behind. + +## Notes + +- The function you pass to `runSyncedWrite` must be synchronous, because it runs + in a transaction. Do any async work before the call. If the function returns a + promise, the framework rejects it and rolls the write back. +- Do not poll on a timer while the Durable Object is idle, because that keeps it + awake and stops it from hibernating. Start a server write from a real event, + e.g. a webhook, an alarm, or a mutation. +- A server write has no client to confirm to, so there is no transaction id, no + receipt, and no duplicate check. Make writes idempotent, e.g. `INSERT OR + IGNORE` on the client-supplied key. + +## See also + +- ADR-0006 explains why a server write goes through `runSyncedWrite`. +- ADR-0004 explains `afterCommit`. +- `examples/board` seeds and bumps rows with `runSyncedWrite`. From d294cb890147741a030321688dd52771bcc34738 Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Tue, 30 Jun 2026 11:58:13 +1000 Subject: [PATCH 06/10] docs(commands): keep the authorize note accurate across both branches The note claimed a command's authorize error reaches the client as a generic rejection (the behavior on this branch, ADR-0012). The stacked validation branch makes command authorize/validation errors surface like a mutation's, so that detail would contradict it after a rebase. Drop the branch-specific surfacing claim and keep the part that holds everywhere: both deny by throwing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- recipes/commands-vs-mutations.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/recipes/commands-vs-mutations.md b/recipes/commands-vs-mutations.md index 66aab18..1f24b6b 100644 --- a/recipes/commands-vs-mutations.md +++ b/recipes/commands-vs-mutations.md @@ -85,10 +85,8 @@ returns the count. ## Notes - A mutation's `authorize` denies a write by throwing, and the client receives - that error message. A command's `authorize` can also throw to deny, but the - client receives a generic rejection and the server logs the message - (ADR-0012). If you need to tell the user why a command was refused, return that - in the result instead of throwing. + that error message. A command's `authorize` denies a call the same way, by + throwing. - You can type a command's args, either with a generic (`sync.command<{ before: number }>()(fn)`) or from a schema (`sync.command(zArgs, fn)`). From 7c68d4a70196ddce9615a668633ddd5a4f91c84c Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Tue, 30 Jun 2026 12:48:06 +1000 Subject: [PATCH 07/10] fix(client): brand the transport by its Api; exclude empty command names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two type-soundness fixes from the codex review of PR #19: - WebSocketTransport only used `Api` in its command methods, so two transports with the same (or empty) command maps were interchangeable across DOs — you could pass an InboxDO transport to a RoomDO collection and it type-checked. Add a phantom `__api` brand so a transport for one schema is not assignable where another schema is expected. This catches the mutations-only multi-DO case. - An empty command name `""` type-checked on `sendCall`/`call`, but `wellFormed` drops a call frame with an empty name, so the call would hang to timeout. Exclude `""` from the callable command names, making it harmless dead code at no runtime cost. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- src/client/transport.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/client/transport.ts b/src/client/transport.ts index 6defdcc..1d3b693 100644 --- a/src/client/transport.ts +++ b/src/client/transport.ts @@ -76,15 +76,25 @@ interface TxWaiter { // command map and each command's Args/Result from the phantom carriers the // server's `CommandEntry` attaches — no server import, nothing at runtime. type CommandsOf = Api extends { commands: infer C } ? C : Record -type CommandName = keyof CommandsOf & string +/** Callable command names. `""` is excluded: `wellFormed` drops a call frame + * with an empty name, so a `""` command would hang on the wire; making it + * uncallable here turns that footgun into harmless dead code at no runtime cost. */ +type CommandName = Exclude & string, ""> type ArgsOf = Entry extends { __args?: infer A } ? A : never type ResultOf = Entry extends { __result?: infer R } ? R : never -/** The `transport.call.*` proxy: one method per command, args + result typed. */ +/** The `transport.call.*` proxy: one method per command, args + result typed. + * `""` is remapped away for the same reason as `CommandName`. */ type CallProxy = { - [K in keyof CommandsOf]: (args: ArgsOf[K]>) => Promise[K]>> + [K in keyof CommandsOf as K extends "" ? never : K]: ( + args: ArgsOf[K]>, + ) => Promise[K]>> } export class WebSocketTransport { + /** Phantom brand tying the transport to its schema `Api`, so a transport for + * one DO is not assignable where another DO's schema is expected. `declare` + * keeps it type-only — no runtime field. */ + declare readonly __api?: Api private ws: WebSocketLike | null = null private connectPromise: Promise | null = null private readonly codec: FrameCodec From 70e1c7aa212d76b708ef55ac8acfa61f90fd9d78 Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Tue, 30 Jun 2026 12:48:07 +1000 Subject: [PATCH 08/10] docs: explain why a mutation's execute is synchronous A mutation's `execute` runs inside `transactionSync`, which cannot await, and an async execute could not be atomic with its CDC rows anyway. The type can't forbid async (TS lets an async fn satisfy a void callback), so make the runtime guard's message point to the right homes for async work, and add a recipe note: do async work in authorize, afterCommit, or a command. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- recipes/commands-vs-mutations.md | 6 ++++++ src/server/sync-do.ts | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/recipes/commands-vs-mutations.md b/recipes/commands-vs-mutations.md index 1f24b6b..407900d 100644 --- a/recipes/commands-vs-mutations.md +++ b/recipes/commands-vs-mutations.md @@ -90,6 +90,12 @@ returns the count. - You can type a command's args, either with a generic (`sync.command<{ before: number }>()(fn)`) or from a schema (`sync.command(zArgs, fn)`). +- A mutation's `execute` must be synchronous, because it runs inside the + transaction that commits the row and its change-log entry together. Writing to + the Durable Object's own SQLite is synchronous, so this is the normal case. Do + any async work in `authorize` (it runs before the transaction), in + `afterCommit` (it runs after the commit), or in a command (it runs outside a + transaction). ## See also diff --git a/src/server/sync-do.ts b/src/server/sync-do.ts index fc2d863..deef9eb 100644 --- a/src/server/sync-do.ts +++ b/src/server/sync-do.ts @@ -370,7 +370,12 @@ export abstract class SyncDurableObject extends const def = this.registry.mutations.get(`${f.collection}:${op.type}`)! const result = def.execute({ user, op, sql: this.sql, env: this.env }) as unknown if (result !== undefined && typeof (result as PromiseLike).then === "function") { - throw new Error(`mutation '${f.collection}:${op.type}' execute must be synchronous`) + // `execute` runs inside transactionSync, which cannot await; an async + // execute also can't be atomic with its CDC rows. Do async work in + // `authorize` (pre-tx), `afterCommit` (post-commit), or a command. + throw new Error( + `mutation '${f.collection}:${op.type}' execute must be synchronous — do async work in authorize, afterCommit, or a command`, + ) } } }) From 4f9ea91ca7ff11218c1b1f767487fdac1dbb47bf Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Tue, 30 Jun 2026 13:18:18 +1000 Subject: [PATCH 09/10] refactor(client)!: one typed doCollectionOptions form; drop explicit-Row (codex #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finish the branch's purpose for collection creation: there is now ONE way to make a collection, derived from the schema. The explicit-Row overload `doCollectionOptions({ ... })` and the `DoCollectionOptions` type are removed. The single Api-driven form infers `Api` from the (branded) transport and the row from the `table` literal, so the call is just `doCollectionOptions({ transport, table, getKey })`, and a typo'd table is a type error rather than falling through to a catch-all (closes codex review #3). The explicit-Row form was a pre-schema vestige — end users always have the schema `Api`, so it was only ever a second way to do the same thing. Migrate the test suite to the typed form (export `TestApi` from test-worker; type the transports ``; drop the ``/`` args, row now inferred) and simplify the examples to the zero-type-arg form. Type-only change; 171/171 unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr --- examples/board/src/client.tsx | 2 +- examples/chat/src/client.tsx | 2 +- examples/multi-do/src/client.tsx | 4 +-- examples/on-demand/src/client.tsx | 2 +- src/client/do-collection.ts | 41 +++++++++++++------------------ src/client/index.ts | 7 +----- tests/do-collection.test.ts | 11 +++++---- tests/e2e.test.ts | 16 +++++------- tests/filtered-client.test.ts | 9 ++++--- tests/live-query.test.ts | 12 +++------ tests/multiplex.test.ts | 16 +++--------- tests/on-demand.test.ts | 25 ++++++++++--------- tests/reinsert-catchup.test.ts | 12 +++------ tests/server-write.test.ts | 14 ++++------- tests/test-worker.ts | 4 +++ 15 files changed, 74 insertions(+), 103 deletions(-) diff --git a/examples/board/src/client.tsx b/examples/board/src/client.tsx index ba6fcf4..f186e6f 100644 --- a/examples/board/src/client.tsx +++ b/examples/board/src/client.tsx @@ -32,7 +32,7 @@ const wsProto = location.protocol === "https:" ? "wss:" : "ws:" const transport = new WebSocketTransport({ url: `${wsProto}//${location.host}/sync?${qs}` }) const tasks = createCollection( // Row (Task) is inferred from BoardApi + the "tasks" table — no runtime schema. - doCollectionOptions({ transport, table: "tasks", getKey: (t) => t.id, syncMode: "on-demand" }), + doCollectionOptions({ transport, table: "tasks", getKey: (t) => t.id, syncMode: "on-demand" }), ) // A range index on the order column lets the live query page lazily via the // cursor instead of falling back to loading the whole subset. BTreeIndex suits diff --git a/examples/chat/src/client.tsx b/examples/chat/src/client.tsx index aabd0ec..afe19a9 100644 --- a/examples/chat/src/client.tsx +++ b/examples/chat/src/client.tsx @@ -26,7 +26,7 @@ const transport = new WebSocketTransport({ // One transport per DO, shared by every collection on it. The Row type is // inferred from ChatApi + the table name — no runtime schema value needed. const messages = createCollection( - doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), + doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), ) function App(): JSX.Element { diff --git a/examples/multi-do/src/client.tsx b/examples/multi-do/src/client.tsx index 4b85ac2..8801ba0 100644 --- a/examples/multi-do/src/client.tsx +++ b/examples/multi-do/src/client.tsx @@ -40,10 +40,10 @@ const inboxTransport = new WebSocketTransport({ // One collection per (DO, table). Row is inferred from the Api + table — no // runtime schema value crosses the wire. const messages = createCollection( - doCollectionOptions({ transport: roomTransport, table: "messages", getKey: (m) => m.id }), + doCollectionOptions({ transport: roomTransport, table: "messages", getKey: (m) => m.id }), ) const notifications = createCollection( - doCollectionOptions({ + doCollectionOptions({ transport: inboxTransport, table: "notifications", getKey: (n) => n.id, diff --git a/examples/on-demand/src/client.tsx b/examples/on-demand/src/client.tsx index 685c7bc..b54dded 100644 --- a/examples/on-demand/src/client.tsx +++ b/examples/on-demand/src/client.tsx @@ -18,7 +18,7 @@ const transport = new WebSocketTransport({ }) const items = createCollection( - doCollectionOptions({ transport, table: "items", getKey: (i) => i.id, syncMode: "on-demand" }), + doCollectionOptions({ transport, table: "items", getKey: (i) => i.id, syncMode: "on-demand" }), ) // Mounting requests this category's subset; unmounting releases it. diff --git a/src/client/do-collection.ts b/src/client/do-collection.ts index a1e7fd4..d6b8fd9 100644 --- a/src/client/do-collection.ts +++ b/src/client/do-collection.ts @@ -42,24 +42,6 @@ export type RowOf = K extends keyof CollectionsOf { - /** One transport per DO; shared by all collections on that DO. */ - transport: WebSocketTransport - /** Collection (table) name on the DO. */ - table: string - /** Stable client-supplied key extractor (must match the server pk). */ - getKey: (row: T) => string - /** Collection id; defaults to the table name. */ - id?: string - /** - * 'eager' (default) syncs the whole collection (optionally filtered by - * `where`). 'on-demand' syncs only the subsets that live queries request. - */ - syncMode?: "eager" | "on-demand" - /** Eager-mode server-side filter + write preflight (a @tanstack/db IR). */ - where?: unknown -} - interface PendingMutationLike { type: RowOp key: string @@ -108,15 +90,26 @@ export interface DoApiCollectionOptions> { where?: unknown } -// Api-driven: `doCollectionOptions({ transport, table, getKey })` -// — Row inferred from the schema. Listed first so a zero-type-arg call infers -// Api from the transport rather than collapsing Row to the explicit-T overload. +// The schema `Api` is the single source of truth: `Api` is inferred from the +// (branded) transport and the table key from the `table` literal, so the row +// type follows and a table that isn't a collection of `Api` is a type error. +// +// const messages = createCollection( +// doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), +// ) +// +// Explicit type args are optional (`doCollectionOptions(...)`). export function doCollectionOptions>( opts: DoApiCollectionOptions, ): CollectionConfig & object, string> -// Explicit-Row: `doCollectionOptions({ transport, table, getKey })`. -export function doCollectionOptions(opts: DoCollectionOptions): CollectionConfig -export function doCollectionOptions(opts: DoCollectionOptions): CollectionConfig { +export function doCollectionOptions(opts: { + transport: WebSocketTransport + table: string + getKey: (row: any) => string + id?: string + syncMode?: "eager" | "on-demand" + where?: unknown +}): CollectionConfig { const { transport, table, getKey, where } = opts const syncMode = opts.syncMode ?? "eager" const eagerSubId = `${table}#${++subSeq}` diff --git a/src/client/index.ts b/src/client/index.ts index 35df955..8d81ea4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -14,9 +14,4 @@ export { } from "./transport.ts" export type { SubHandler, TransportOptions, WebSocketLike } from "./transport.ts" export { doCollectionOptions, WriteOutsideSubError } from "./do-collection.ts" -export type { - CollectionName, - DoApiCollectionOptions, - DoCollectionOptions, - RowOf, -} from "./do-collection.ts" +export type { CollectionName, DoApiCollectionOptions, RowOf } from "./do-collection.ts" diff --git a/tests/do-collection.test.ts b/tests/do-collection.test.ts index 462ca41..cf332c5 100644 --- a/tests/do-collection.test.ts +++ b/tests/do-collection.test.ts @@ -2,6 +2,7 @@ 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 type { TestApi } from "./test-worker.ts" // WHY: the adapter is the seam between the transport and TanStack DB's sync // API. createCollection just consumes the config it returns, so the behaviour @@ -17,8 +18,8 @@ interface Msg { body: string } -function connect(room: string): Promise { - const t = new WebSocketTransport({ +function connect(room: string): Promise> { + const t = new WebSocketTransport({ url: `https://example.com/sync/${room}`, open: async () => { const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) @@ -42,9 +43,9 @@ async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise { type Call = [string, unknown?] /** Spy sync controls + the bound sync function from a fresh adapter. */ -function startSync(transport: WebSocketTransport): { calls: Array } { +function startSync(transport: WebSocketTransport): { calls: Array } { const calls: Array = [] - const opts = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id }) + const opts = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id }) // sync lives on opts.sync.sync; invoke with spy controls (cast: type-only dep). const syncConfig = (opts as unknown as { sync: { sync: (p: unknown) => void } }).sync syncConfig.sync({ @@ -90,7 +91,7 @@ describe("doCollectionOptions (M3 adapter)", () => { it("sends a mut and lands the confirming delta as a synced write before resolving", async () => { const room = "dc-mut" const t = await connect(room) - const adapter = doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id }) + const adapter = doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id }) const calls: Array = [] ;(adapter as unknown as { sync: { sync: (p: unknown) => void } }).sync.sync({ collection: { get: () => undefined }, diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index abfb38d..2d3126a 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -3,6 +3,7 @@ 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 type { TestApi } from "./test-worker.ts" // WHY: the real proof of the stack — a genuine @tanstack/db createCollection, // driven by our adapter + transport, against a real DO in workerd (not spy @@ -11,13 +12,8 @@ import { WebSocketTransport, type WebSocketLike } from "../src/client/transport. // the actual TanStack runtime. Possible because createCollection runs in // workerd (verified by probes). -interface Msg { - id: string - body: string -} - -function makeTransport(room: string): WebSocketTransport { - return new WebSocketTransport({ +function makeTransport(room: string): WebSocketTransport { + return new WebSocketTransport({ url: `https://example.com/sync/${room}`, open: async () => { const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) @@ -40,7 +36,7 @@ describe("end-to-end: createCollection + adapter + transport + DO", () => { }) const messages = createCollection( - doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id }), + doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id }), ) await messages.preload() // starts sync -> subscribe -> snapshot -> markReady @@ -59,8 +55,8 @@ describe("end-to-end: createCollection + adapter + transport + DO", () => { const tb = makeTransport(room) await Promise.all([ta.connect(), tb.connect()]) - const a = createCollection(doCollectionOptions({ transport: ta, table: "messages", getKey: (r) => r.id })) - const b = createCollection(doCollectionOptions({ transport: tb, table: "messages", getKey: (r) => r.id })) + const a = createCollection(doCollectionOptions({ transport: ta, table: "messages", getKey: (r) => r.id })) + const b = createCollection(doCollectionOptions({ transport: tb, table: "messages", getKey: (r) => r.id })) await Promise.all([a.preload(), b.preload()]) await a.insert({ id: "x", body: "from-a" }).isPersisted.promise diff --git a/tests/filtered-client.test.ts b/tests/filtered-client.test.ts index cc1810e..3454b53 100644 --- a/tests/filtered-client.test.ts +++ b/tests/filtered-client.test.ts @@ -2,6 +2,7 @@ import { env, runInDurableObject, SELF } from "cloudflare:test" import { describe, expect, it } from "vitest" import { doCollectionOptions, WriteOutsideSubError } from "../src/client/do-collection.ts" import { WebSocketTransport, type WebSocketLike } from "../src/client/transport.ts" +import type { TestApi } from "./test-worker.ts" // WHY: a filtered collection must (a) sync only the matching subset from the // server, and (b) reject a write whose row would fall outside the filter before @@ -23,8 +24,8 @@ const whereEq = (field: string, value: unknown): unknown => ({ ], }) -function connect(room: string): Promise { - const t = new WebSocketTransport({ +function connect(room: string): Promise> { + const t = new WebSocketTransport({ url: `https://example.com/sync/${room}`, open: async () => { const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) @@ -47,9 +48,9 @@ async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise { type Call = [string, unknown?] -function startFiltered(transport: WebSocketTransport, where: unknown): { calls: Array; adapter: ReturnType> } { +function startFiltered(transport: WebSocketTransport, where: unknown): { calls: Array; adapter: ReturnType> } { const calls: Array = [] - const adapter = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, where }) + const adapter = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, where }) ;(adapter as unknown as { sync: { sync: (p: unknown) => void } }).sync.sync({ collection: { get: () => undefined }, // adapter consults held keys (held-insert upsert) begin: () => calls.push(["begin"]), diff --git a/tests/live-query.test.ts b/tests/live-query.test.ts index 96f45d1..fc189d4 100644 --- a/tests/live-query.test.ts +++ b/tests/live-query.test.ts @@ -3,6 +3,7 @@ import { 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 type { TestApi } from "./test-worker.ts" // WHY: the entire reactive layer (filtering, joins, incremental view // maintenance) is the client's job — the DO only stores + emits, never runs @@ -10,13 +11,8 @@ import { WebSocketTransport, type WebSocketLike } from "../src/client/transport. // DO-backed collection and updates incrementally as synced data changes: the DO // streams every row, the client's IVM derives the filtered view. -interface Msg { - id: string - body: string -} - -function makeTransport(room: string): WebSocketTransport { - return new WebSocketTransport({ +function makeTransport(room: string): WebSocketTransport { + return new WebSocketTransport({ url: `https://example.com/sync/${room}`, open: async () => { const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) @@ -40,7 +36,7 @@ describe("client live query / IVM over a DO-backed collection (M8)", () => { it("derives a filtered view client-side and updates it incrementally", async () => { const t = makeTransport("lq") await t.connect() - const messages = createCollection(doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id })) + const messages = createCollection(doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id })) await messages.preload() // A live query filtering client-side — the DO sends every row; IVM filters. diff --git a/tests/multiplex.test.ts b/tests/multiplex.test.ts index c547782..269af88 100644 --- a/tests/multiplex.test.ts +++ b/tests/multiplex.test.ts @@ -3,26 +3,18 @@ import { 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 type { TestApi } from "./test-worker.ts" // WHY: a session typically watches several tables of one DO at once. The design // multiplexes all of them over a SINGLE WebSocket (transport demuxes by subId, // server routes by collection). This pins that two collections sync // independently over exactly one socket — one connection per DO, not per table. -interface Msg { - id: string - body: string -} -interface File { - id: string - name: string -} - describe("multi-collection multiplexing over one WS (M8)", () => { it("syncs two collections over a single shared socket", async () => { const room = "mux" const sockets: Array = [] - const transport = new WebSocketTransport({ + const transport = new WebSocketTransport({ url: `https://example.com/sync/${room}`, open: async () => { const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) @@ -35,8 +27,8 @@ describe("multi-collection multiplexing over one WS (M8)", () => { }, }) - const messages = createCollection(doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id })) - const files = createCollection(doCollectionOptions({ transport, table: "files", getKey: (r) => r.id })) + const messages = createCollection(doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id })) + const files = createCollection(doCollectionOptions({ transport, table: "files", getKey: (r) => r.id })) await Promise.all([messages.preload(), files.preload()]) await messages.insert({ id: "m1", body: "hi" }).isPersisted.promise diff --git a/tests/on-demand.test.ts b/tests/on-demand.test.ts index dbcd6ce..530663a 100644 --- a/tests/on-demand.test.ts +++ b/tests/on-demand.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest" import { doCollectionOptions } from "../src/client/do-collection.ts" import { type SubHandler, WebSocketTransport, type WebSocketLike } from "../src/client/transport.ts" import type { ClientFrame } from "../src/wire/frames.ts" +import type { TestApi } from "./test-worker.ts" // WHY: on-demand loads ONLY the subsets live queries request, instead of // syncing the whole collection. These pin: distinct `where`s become distinct @@ -66,7 +67,7 @@ function fakeTransport(page: Array = []) { }, sendMut: async () => ({}), close: () => {}, - } as unknown as WebSocketTransport + } as unknown as WebSocketTransport return { subs, unsubs, fetches, transport } } @@ -80,9 +81,9 @@ type OnDemand = { loadSubset: (o: LoadOpts) => true | Promise unloadSubset: (o: LoadOpts) => void } -const startOnDemand = (transport: WebSocketTransport, present?: Set) => { +const startOnDemand = (transport: WebSocketTransport, present?: Set) => { const { calls, controls } = spyControls(present) - const adapter = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, syncMode: "on-demand" }) + const adapter = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, syncMode: "on-demand" }) const res = (adapter as unknown as { sync: { sync: (p: unknown) => OnDemand } }).sync.sync(controls) return { calls, res } } @@ -132,7 +133,7 @@ describe("on-demand loadSubset (M11) — refcounting", () => { unsubscribe: () => {}, sendMut: async () => ({}), close: () => {}, - } as unknown as WebSocketTransport + } as unknown as WebSocketTransport const { res } = startOnDemand(rejectingTransport) // Resolves (does not hang); a 50ms race guards against regression. @@ -251,9 +252,9 @@ describe("on-demand loadSubset (M12) — cursor load-more (scroll-back)", () => ], sendMut: async () => ({}), close: () => {}, - } as unknown as WebSocketTransport + } as unknown as WebSocketTransport - const adapter = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, syncMode: "on-demand" }) + const adapter = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, syncMode: "on-demand" }) const res = (adapter as unknown as { sync: { sync: (p: unknown) => OnDemand } }).sync.sync(controls) await res.loadSubset({ where: whereEq("room", "r1") }) // initial sub -> captures liveHandler @@ -271,8 +272,8 @@ describe("on-demand loadSubset (M12) — cursor load-more (scroll-back)", () => }) describe("on-demand loadSubset (M11) — against the DO", () => { - function realTransport(room: string): WebSocketTransport { - return new WebSocketTransport({ + function realTransport(room: string): WebSocketTransport { + return new WebSocketTransport({ url: `https://example.com/sync/${room}`, open: async () => { const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) @@ -293,7 +294,7 @@ describe("on-demand loadSubset (M11) — against the DO", () => { }) const messages = createCollection( - doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id, syncMode: "on-demand" }), + doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id, syncMode: "on-demand" }), ) await messages.preload() // ready, but empty — nothing synced eagerly expect(messages.size).toBe(0) @@ -320,7 +321,7 @@ describe("on-demand loadSubset (M11) — against the DO", () => { }) const messages = createCollection( - doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id, syncMode: "on-demand" }), + doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id, syncMode: "on-demand" }), ) await messages.preload() @@ -440,7 +441,7 @@ describe("on-demand loadSubset (M11) — against the DO", () => { }) const messages = createCollection( - doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id, syncMode: "on-demand" }), + doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id, syncMode: "on-demand" }), ) await messages.preload() // Bounded window — top 3 by body desc (m5, m4, m3). m0 is cold. @@ -467,7 +468,7 @@ describe("on-demand loadSubset (M11) — against the DO", () => { const t = realTransport(room) await t.connect() const messages = createCollection( - doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id, syncMode: "on-demand" }), + doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id, syncMode: "on-demand" }), ) await messages.preload() const kept = createLiveQueryCollection((q) => q.from({ m: messages }).where(({ m }) => eq(m.body, "keep"))) diff --git a/tests/reinsert-catchup.test.ts b/tests/reinsert-catchup.test.ts index 487044f..b21355d 100644 --- a/tests/reinsert-catchup.test.ts +++ b/tests/reinsert-catchup.test.ts @@ -3,6 +3,7 @@ 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 type { TestApi } from "./test-worker.ts" // WHY: a catch-up (reconnect or SSR hydration) emits the LATEST CDC op per // changed key. A key that was deleted-and-reinserted while the client was away @@ -14,13 +15,8 @@ import { WebSocketTransport, type WebSocketLike } from "../src/client/transport. // held-key "insert" as the upsert it semantically is (ADR-0011 D4) — the same // update-upsert contract move-in already relies on (ADR-0002 C4). -interface Msg { - id: string - body: string -} - -function makeTransport(room: string): WebSocketTransport { - return new WebSocketTransport({ +function makeTransport(room: string): WebSocketTransport { + return new WebSocketTransport({ url: `https://example.com/sync/${room}`, reconnectDelayMs: 20, open: async () => { @@ -53,7 +49,7 @@ describe("catch-up reinsert lands as an upsert, not a DuplicateKeySyncError", () s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('k','v1')") }) - const messages = createCollection(doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id })) + const messages = createCollection(doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id })) await messages.preload() expect(messages.get("k")).toMatchObject({ id: "k", body: "v1" }) diff --git a/tests/server-write.test.ts b/tests/server-write.test.ts index acfe893..aa87e87 100644 --- a/tests/server-write.test.ts +++ b/tests/server-write.test.ts @@ -3,6 +3,7 @@ import type { SqlStorage } from "@cloudflare/workers-types" import { env, runInDurableObject, SELF } from "cloudflare:test" import { describe, expect, it } from "vitest" import { doCollectionOptions, type WebSocketLike, WebSocketTransport } from "../src/client/index.ts" +import type { TestApi } from "./test-worker.ts" // WHY: server-originated writes (an agent inserting a row, a webhook, a cron // job, a bulk seed) live outside the client mutation flow — no txId, no receipt. @@ -11,11 +12,6 @@ import { doCollectionOptions, type WebSocketLike, WebSocketTransport } from "../ // A raw `sql.exec` without it fires the triggers but never broadcasts until some // later mutation drains the backlog. -interface Msg { - id: string - body: string -} - // runSyncedWrite is protected (subclass-facing); reach it in the test via the // in-DO instance. registerSync already ran in the DO constructor (ADR-0007). type ServerApi = { @@ -23,8 +19,8 @@ type ServerApi = { } const api = (i: unknown): ServerApi => i as unknown as ServerApi -function realTransport(room: string): WebSocketTransport { - return new WebSocketTransport({ +function realTransport(room: string): WebSocketTransport { + return new WebSocketTransport({ url: `https://example.com/sync/${room}`, open: async () => { const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) @@ -50,7 +46,7 @@ describe("runSyncedWrite (ADR-0006) — server-originated writes", () => { const stub = env.SYNC_DO.get(env.SYNC_DO.idFromName(room)) const t = realTransport(room) await t.connect() // constructing the DO already ran registerSync (ADR-0007) - const messages = createCollection(doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id })) + const messages = createCollection(doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id })) await messages.preload() expect(messages.size).toBe(0) @@ -74,7 +70,7 @@ describe("runSyncedWrite (ADR-0006) — server-originated writes", () => { const t = realTransport(room) await t.connect() - const messages = createCollection(doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id })) + const messages = createCollection(doCollectionOptions({ transport: t, table: "messages", getKey: (m) => m.id })) await messages.preload() await waitFor(() => messages.get("agent2") !== undefined) expect(messages.get("agent2")).toMatchObject({ id: "agent2", body: "queued" }) diff --git a/tests/test-worker.ts b/tests/test-worker.ts index 51ad97e..7ea64a0 100644 --- a/tests/test-worker.ts +++ b/tests/test-worker.ts @@ -103,6 +103,10 @@ const testSchema = sync.schema({ }, }) +/** The schema's type, for client-side tests to type their transport + collections + * (`new WebSocketTransport(...)`, `doCollectionOptions`). */ +export type TestApi = typeof testSchema + export class SyncTestDO extends SyncDurableObject { constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) From af4f33b64344364c551b7dd92e1b9c5264d94c88 Mon Sep 17 00:00:00 2001 From: Tom McKenzie Date: Wed, 1 Jul 2026 12:08:15 +1000 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20pre-merge=20pass=20=E2=80=94=20li?= =?UTF-8?q?nk=20recipes,=20drop=20stale=20API=20name,=20honest=20multi-do?= =?UTF-8?q?=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confidence pass over the object-schema branch before merge: - README: add the multi-do example and a "Common patterns and recipes" list linking each recipe in recipes/ (the folder had no inbound links). - ADR-0004: drop the dead `defineCommand` name from an Accepted (current-reading) ADR body; the concept sentence stands without it. - examples/multi-do: note that InboxDO's per-user boundary is illustrative, not enforced (`/inbox/:user` names the DO, `?user=` names the caller; the example does not bind them). - recipes/commands-vs-mutations: a command's authorize/execute errors are sanitized, so the client gets a generic rejection, not the thrown text. Co-Authored-By: Claude Opus 4.8 --- README.md | 19 +++++++++++++++++++ docs/adr/0004-after-commit-hook.md | 2 +- examples/multi-do/src/worker.ts | 6 ++++++ recipes/commands-vs-mutations.md | 3 ++- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 636aa2f..1229520 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,10 @@ browser-verified. scroll-back, and a mutable order key so voting bumps a task to the top (move-in). Its firehose makes the deferred bounded-window-under-churn limitation visible — `loaded` climbs past `window`. +- **[`examples/multi-do`](./examples/multi-do)** — two separate DOs (a room and + an inbox) behind one Worker: one transport per DO, each typed by its own `Api` + so `transport.call.*` is scoped to that DO's commands, and a cross-DO feed + merged client-side (the DO never joins — ADR-0001). > [!TIP] > Using on-demand with `orderBy` + `limit`? Add a **range index** on the order @@ -279,6 +283,21 @@ browser-verified. --- +## Common patterns and recipes + +Task-oriented guides in [`recipes/`](./recipes): + +- **[Commands vs mutations](./recipes/commands-vs-mutations.md)** — when a write + is a typed `insert`/`update`/`delete` and when it's a named command. +- **[End-to-end types](./recipes/end-to-end-types.md)** — share one schema type + between server and client so the transport, commands, and collections are typed. +- **[On-demand and windows](./recipes/on-demand-and-windows.md)** — sync only the + rows a query asks for, and grow a bounded window as the user scrolls. +- **[Server-originated writes](./recipes/server-originated-writes.md)** — write + rows from the DO itself (webhooks, jobs, seeds) so clients still see them. + +--- + ## Non-goals - **Multi-DO transactions.** A transaction touches collections in one DO. diff --git a/docs/adr/0004-after-commit-hook.md b/docs/adr/0004-after-commit-hook.md index 0008157..e0e8700 100644 --- a/docs/adr/0004-after-commit-hook.md +++ b/docs/adr/0004-after-commit-hook.md @@ -22,7 +22,7 @@ The two obvious homes are both wrong: there cannot be awaited, holds the write path, and is not atomic with the external system — the transaction can roll back but R2 cannot. -A `command` (`defineCommand`) *can* do async work in `execute` (it runs outside +A `command` *can* do async work in `execute` (it runs outside any transaction), and remains the right tool for RPC-shaped operations. But it is not the collection's optimistic mutation path, and its only durability is client-retry-driven dedup. We still want post-work attached to ordinary diff --git a/examples/multi-do/src/worker.ts b/examples/multi-do/src/worker.ts index a5b7721..08c0a4d 100644 --- a/examples/multi-do/src/worker.ts +++ b/examples/multi-do/src/worker.ts @@ -50,6 +50,12 @@ export class InboxDO extends SyncDurableObject { }) } + // Like RoomDO, the example trusts a `user` query param for identity. NOTE the + // path (`/inbox/:user`) names the DO while `?user=` names the caller — the + // example does not bind them, so `/inbox/alice/sync?user=bob` would let bob + // into alice's inbox. A real app verifies a token at the Worker and either + // rejects a path/caller mismatch there or authorizes each inbox write against + // the DO owner; the per-user boundary here is illustrative, not enforced. protected override parseAttachment(req: Request): Claims { return { userId: new URL(req.url).searchParams.get("user") ?? "anon" } } diff --git a/recipes/commands-vs-mutations.md b/recipes/commands-vs-mutations.md index 407900d..7edb4b0 100644 --- a/recipes/commands-vs-mutations.md +++ b/recipes/commands-vs-mutations.md @@ -86,7 +86,8 @@ returns the count. - A mutation's `authorize` denies a write by throwing, and the client receives that error message. A command's `authorize` denies a call the same way, by - throwing. + throwing, but a command's errors are sanitized — the client receives a generic + rejection rather than the thrown text. - You can type a command's args, either with a generic (`sync.command<{ before: number }>()(fn)`) or from a schema (`sync.command(zArgs, fn)`).