← All posts
Fix

spark-ssr 1.3.1 — live now fires on raw writes too

spark-ssr 1.3.1 is a focused fix that makes the live attribute do what the docs already promised: every write through the server pings hydrated tabs over SSE — including the hand-written db.query() writes inside your own custom endpoints.

The bug

Adding live to a table block is supposed to be realtime as one attribute: a write happens, every open tab refetches through its own session. That worked — but only for writes that went through the auto-generated /api/<table> CRUD route. A custom api/*.html endpoint gets a deliberately raw, unintercepted db handle, and its db.query("INSERT INTO …") never pinged the live channel.

That bites hardest on exactly the pages most likely to want live: anything with logic beyond single-row CRUD — a join request that needs a duplicate check, an approve that moves a row between tables plus writes a notification, a draw that writes N rows in a loop — is forced into a custom endpoint in the first place, and then silently got no live updates.

<!-- notifications is declared live… -->
<spark-ssr table="notifications" live />

<!-- …but this hand-written INSERT never reached an open tab -->
<!-- api/approve.html -->
<script>
  await db.query(
    "INSERT INTO notifications (user_id, kind) VALUES (?, 'approved')",
    [hostId],
  );
</script>

The fix

spark-ssr now wraps the db handle it hands to a custom endpoint. Reads pass straight through; writes record which tables they touched and fan out to live tabs, response-cache invalidation, and job hooks — the same automation the generated CRUD route already gets. The broadcast is coalesced per request and flushed once the handler returns, so an endpoint that writes N rows in a loop pings each touched table exactly once, never N back-to-back refresh storms.

// The raw handle stays raw for reads and for db.raw; only db.query()
// writes are observed, and the broadcast is deduped by table per request.
const liveDb = (rawDb) => {
  const touched = new Map();
  return {
    ...rawDb,
    async query(sql, values) {
      const r = await rawDb.query(sql, values);
      const event = writeEvent(sql);           // insert / update / delete
      if (event) for (const t of sqlTables(sql)) touched.set(t, event);
      return r;
    },
    flushLive() { for (const [t, e] of touched) fireEvent(e, t, null); },
  };
};

There's a correctness bonus riding along: because the broadcast path also invalidates the full-page response cache, a raw custom-endpoint write now busts the cache for its tables too. Previously an anonymous cached page could serve stale data after a custom write until its TTL — that's fixed as well.

Nothing changes unless you opt in

This is purely additive. A table only broadcasts if some page declared it live; if you never write live, behavior is byte-identical to 1.3.0. The db.raw driver handle stays a full manual escape hatch — its writes are never intercepted. The page render path and the auto-CRUD path are untouched.

A live header badge, declaratively

Because a layout's <spark-ssr> vars are in scope for every page it wraps, declaring the table live in a layout makes a shared piece of chrome — a notifications badge, say — update live on any hydrating page, with no client code:

<!-- pages/_layout.html -->
<span class="badge" :hidden="!(unread.n > 0)">{unread.n}</span>

<spark-ssr>
  unread = SELECT COUNT(*) AS n FROM notifications
           WHERE user_id = :session.id AND IFNULL(read, 0) = 0
</spark-ssr>
<spark-ssr table="notifications" live />

A write lands, the tab refetches, and the badge re-renders. (The one precondition is that the page hydrates — a purely static page has no client runtime to update, but it still renders the correct count on every load.)

Verifying it

The fix ships with a removal-sensitive test — a raw db.query() write in a custom endpoint must ping /__spark/live, and the test fails if the wrapper is reverted. The full suite is green, and the render benchmark is unmoved (the fix never touches the hot loop):

spark-ssr: 107 passed, 0 failed
1000-row render  p50 4.3 ms   ·   /big  ~9–10k req/s
✅ within baseline

Upgrading

1.3.1 is a pure bug fix with no breaking changes — bump your spark-ssr range and you're done:

bun add spark-ssr@^1.3.1

Full details: spark-ssr on npm · ssr-v1.3.1 on GitHub.