Skip to content

v0.7.0 — SSR, and on-demand rebuilt for TanStack DB 0.9

Latest

Choose a tag to compare

@grrowl grrowl released this 25 Sep 07:32
· 1 commit to main since this release

Added

  • SSR support (experimental, ADR-0011) — built on TanStack DB's merged
    SSR API (DbClient dehydrate()/hydrate(), PR
    #1564, shipped in
    @tanstack/db 0.8.0).
    • Server: readSyncSnapshot(req, request) — one consistent
      {rows, cursor} read over the DO binding, no WebSocket. The required
      request runs through parseAttachment: one auth gate for the socket
      and the read path. The cursor is a durable high-water mark
      (max(currentSeq, drainCursor) — robust to retention pruning); "0"
      honestly means "no resume point". RPC rows normalize BLOB
      ArrayBuffer → Uint8Array for wire-codec parity (ADR-0017).
    • Client: SsrSnapshotTransport (read-only; per-request; swapped at
      the new structural Transport<Api> seam), syncMeta cursor round-trip
      ({v, cursor, where-fingerprint}; fail-loud-but-safe import/merge),
      since on the first sub, seedCursor (a late chunk regresses and
      replays via a forced reconnect), always-armed eager snapshot reconcile
      (authoritative set semantics — no flash-to-empty, no stranded deletes),
      on-demand transient catch-up with honest truncate for unresumable rows.
    • Wire (additive): uptodate gains optional sub (a catch-up's
      terminal is sub-scoped); the first sub may carry since.
  • New upstream contracts adopted: commit() receipts
    (SyncAppliedReceipt, 0.8.5) — subset loads settle only once rows are
    visible; markError (0.8.2) — a failed first connect fails preload()
    loud with the cause instead of hanging (a retried preload() recovers);
    per-subset load failures reject that subset's promise (0.8.4);
    withCollectionConfigFactory (0.8.0) — doCollectionOptions configs work
    as collectionOptions(id, …) descriptors with fresh adapter state per
    DbClient.

Changed

  • Peer dependency: @tanstack/db >= 0.8.6 (was >= 0.6.0). Breaking. The SSR
    hooks shipped in 0.8.0; 0.8.5 carries the commit()-receipt contract and
    descriptor reuse this adapter adopts; 0.8.6 is the first release whose
    module evaluation is Worker-safe (0.8.5 calls crypto.getRandomValues() at
    module scope, so a Worker rendering SSR fails to start). The range stays
    uncapped (ADR-0022). The 0.6-era API is otherwise unchanged: the full
    pre-lift suite passed on 0.8.5 without modification.
  • The transport ignores STREAM frames from an abandoned socket
    (identity-guarded message dispatch): only the current socket speaks for the
    stream; dropped frames are re-covered by the resubscribe catch-up from the
    applied cursor. Mutation receipts (committed/rejected) still settle their
    waiters from a stale socket — they are not re-covered by any replay — but
    never advance the cursor. A page from a stale socket fails its fetch: the
    fresh socket's replay may already have deleted a row it carries (ADR-0023).
  • WebSocketTransport.close() is now revivable (ADR-0020). A later
    connect() clears the intentional-close latch, restoring auto-reconnect and
    onClosed delivery on a reused transport. Previously close() was permanent
    across a connect(): a revived transport (connection pools reuse instances)
    silently lost both. Code that relied on close() being permanently terminal
    must not re-connect() the same instance.
  • The injectable open gains an optional AbortSignal (ADR-0020).
    Additive and backward-compatible — existing openers compile and run
    unchanged — but a custom opener should honour it (close its socket, reject)
    so close() during an in-flight handshake frees a still-CONNECTING socket.
  • New export ConnectionLostError (ADR-0021, issue #39). Settles an
    in-flight mut/call whose socket dropped unexpectedly before its receipt
    arrived, when no reconnect+replay can resolve the true outcome — catchable and
    instanceof-distinct from MutationRejectedError, TransportClosedError, and
    the generic confirmation timeout. The type is the contract: an app catches it
    to hold its optimistic overlay (the outcome is unknown) rather than roll back.
  • Examples updated to @tanstack/db 0.9 / @tanstack/react-db 0.4, each bundling exactly one @tanstack/db; CI now typechecks, builds and boots every example (ADR-0024).
  • On-demand: a released query's rows leave the collection (ADR-0023).
    Releasing a query releases its server subscription and removes the rows only
    it kept current, unless another loaded query still holds them. Before, they
    stayed with nothing keeping them current, so a row deleted meanwhile came back
    after a reload. A row with a pending optimistic write stays visible until the
    write settles. Behaviour change: code that read a released query's rows
    from the collection no longer finds them.

Fixed

  • On-demand requests load exactly what they ask for (ADR-0023). A subset was
    identified by its where alone, so two queries with one filter and different
    orders or limits shared the first one's bounded snapshot (the second showed
    the wrong rows), a growing window without an index never reached the Durable
    Object, and on @tanstack/db 0.9 the new tie, prefix and full-subset requests
    were answered with the first page, so windows stopped paging. Identical
    requests still share one load; a request that an existing subscription
    already keeps current is a one-off fetch.
  • On @tanstack/db 0.9, an ordered window no longer loses rows or stops paging
    (examples/board).
    0.9 requests the rows tied at a page boundary
    separately; each became a live server subscription (piling up against
    maxSubsPerSocket) whose move-out delete removed rows the window's own
    subscription still held, so a new row inserted at the top of the window was
    lost once its write was confirmed. Tie requests are now one-off fetches.
  • A row another loaded query still holds is no longer deleted by a
    neighbour's move-out.
    Every subscription gets a delete for a changed row it
    does not match; the adapter applied it even when another subscription still
    matched the row. A row now leaves only when no subscription holds it.
  • A refused on-demand subscription no longer truncates the collection. An
    unsupported predicate, the per-socket subscription cap or an unknown
    collection wiped every other loaded subset on 0.8, and on 0.9 looped: the
    truncate replay re-subscribed the refused predicate hundreds of times a
    second. A refusal now settles only its own load.
  • A reload no longer receives a released subscription's in-flight snapshot.
    Subscription ids were reused per filter; each subscription now gets a fresh id.
  • Cleaning up an on-demand collection no longer closes a shared transport. It
    unsubscribes what it owns, and other collections on the transport keep
    receiving changes (0.9.1's GC reclaim runs cleanup more often). Behaviour
    change:
    the transport is the caller's; close it with transport.close()
    when you are done with it.
  • A subscription that drops before its first snapshot now loads. On
    reconnect the transport resubscribed it from the shared cursor, so the Durable
    Object answered a catch-up: its rows never arrived and its load never settled
    (eager or on-demand, once another subscription had advanced the cursor). It
    now restarts from a snapshot, undoing what a partial first snapshot
    delivered. An SSR hydration catch-up interrupted the same way resumes from its
    own dehydrated cursor.
  • unloadSubset is idempotent and never throws, and a page whose request was
    aborted installs nothing (@tanstack/db 0.9 subset contracts).
  • #send no longer throws an uncaught Can't call send() after close() when a
    client subscribes then closes before the snapshot finishes streaming (normal
    churn: dispose, navigate-away, StrictMode teardown, forced reconnect). The
    server now skips sends on a non-OPEN socket and treats a post-close send as
    a benign no-op — the socket's webSocketClose already tore down its subs.
    Covers the Broadcaster egress path too (it routes through the same #send).
    Outbound-only; no state impact, no behavior change for OPEN sockets (issue #40).
  • connect() never resolves disconnected (ADR-0020, issue #37). The
    close-epoch discard previously returned, resolving connect() with no
    socket adopted; the awaiting subscribe/sendMut/fetch then sent on a
    null socket and threw, floating an unhandled rejection and leaving the
    collection silently empty. connect() now re-dials a revived transport or
    rejects the new typed TransportClosedError; a racing subscribe either
    lands its frame on the next connection or rejects typed (routed to
    markError, so preload() fails loud and recovers on retry).
  • close() can abort a socket whose open() is still in flight (ADR-0020,
    issue #38).
    Previously close() disposed through this.ws, which is
    null during the handshake, so a slow or never-completing handshake leaked a
    CONNECTING socket forever. close() now aborts the in-flight open() via the
    AbortSignal; the default browser opener honours it, and the close-epoch
    discard remains the backstop for signal-ignoring openers.
  • unsubscribe during an in-flight subscribe's connect no longer sends the
    sub after the socket opens — previously the server persisted a ghost
    subscription (ADR-0019) with no local consumer until the socket dropped.
    Pre-existing; surfaced by the SSR lift's adversary review.
  • An in-flight mut/call is no longer abandoned to its generic timeout when
    the socket drops unexpectedly (ADR-0021, issue #39).
    Server-side, the durable
    commit + broadcast run before the committed send, so a drop in that window
    used to time out and roll back a write that had already succeeded. Now, on an
    unexpected drop with subscriptions active, the transport HOLDS each in-flight
    mut/call and REPLAYS it on reconnect; the server's dedup table answers the
    replayed txId with its true recorded outcome — a committed write resolves
    committed (never a timeout rollback) within the dedup window, a rejected one
    rejects with its real MutationRejectedError. Where no reconnect can resolve it
    (a terminal 4xxx close, or no active subscriptions), or the replay does not
    answer within timeoutMs of the drop, the mutation settles promptly with the
    new typed ConnectionLostError instead of the generic timeout. Timeout
    semantics for a socket that stays open are unchanged.
  • Docs audit against @tanstack/db 0.9 / @tanstack/react-db 0.4. Every
    README and recipe snippet now typechecks. The zod recipe imported defineSync
    from tanstack-durable-object-sync/server, a subpath the package does not
    export; it now imports from the package root. Corrected claims: an authorize
    throw surfaces its reason without the VALIDATION code (only schema failures
    carry it); without a range index an on-demand window can't page lazily;
    on-demand pages can include every row tied at the boundary, and an unlimited
    query loads every matching row; the static where filter is eager-mode only;
    HydrationBoundary needs a DbProvider; and the mixin also adds the public
    readSyncSnapshot.
  • Shipped source maps now resolve: the package includes src/, which every
    .js.map/.d.ts.map references, so go-to-definition lands on the source
    instead of a missing file.
  • A mut op with an empty key ("") is now rejected (ADR-0025). The pk is
    client-supplied TEXT, so "" is never a real row identity. ADR-0014 said the
    wire layer already refused it, but it did not. The server now answers with
    rejected (code: "VALIDATION") before authorize or any row write, so the client
    gets a prompt MutationRejectedError and rolls back. Behaviour change: a
    write that used "" as a pk used to be accepted and is now refused.
  • A replayed mut is no longer rejected by a lowered op limit (ADR-0025
    amendment).
    maxOpsPerMutation was checked before the dedup lookup. If the
    limit was lowered (for example, by a deploy) while a hold-and-replay
    (ADR-0021) was pending, the replayed frame got LIMIT_EXCEEDED and the client
    rolled back a write that had committed. The check now runs after the lookup,
    so a resent mut gets its stored outcome while that receipt is retained.
  • A mut/call replayed while the original is still running no longer runs
    twice (ADR-0025 amendment).
    authorize, and a command's execute, can
    await I/O, and a second frame for the same txId could slip in and run its
    own attempt. Under ADR-0021's hold-and-replay (the socket drops mid-authorize,
    the client replays on reconnect), the replayed mutation failed on its own pk
    and the client rolled back a write that had committed. A replayed command ran
    its side effect a second time. A duplicate now waits for the running attempt
    and replays its outcome. A replayed committed also flushes the socket's
    pending deltas first, so it can't overtake a buffered delta (ADR-0002 C1).

Internal

  • CI tests both ends of the @tanstack/db peer range (ADR-0022): a locked
    leg (the lockfile's version; the devDependency moves to ^0.9.2) and a
    floor leg that installs the version derived from peerDependencies.
  • A pack smoke test (npm run smoke:pack, run in CI) installs the packed
    tarball into a throwaway consumer at the floor and locked versions, checks
    the tarball ships everything its manifest and source maps reference,
    type-checks the shipped declarations with skipLibCheck off, and boots a
    consumer Worker in workerd that renders SSR from a Durable Object.
  • A weekly (and manually dispatchable) workflow runs the suite and the pack
    smoke test against @tanstack/db@latest.