Skip to content

itx SSR/loader prefetch: getServerItx, getLoaderItx, best-effort prefetchItxQuery#1457

Merged
jonastemplestein merged 11 commits into
mainfrom
itx-server-handle
Jun 10, 2026
Merged

itx SSR/loader prefetch: getServerItx, getLoaderItx, best-effort prefetchItxQuery#1457
jonastemplestein merged 11 commits into
mainfrom
itx-server-handle

Conversation

@jonastemplestein

@jonastemplestein jonastemplestein commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Problem

Converted dashboard routes (streams index) fetch only via useItxQuery over the browser's itx WebSocket — route loaders lost their ensureQueryData prefetch, so every visit painted a first-visit spinner.

What this adds (itx DECISIONS D18)

  • src/itx/access.tsaccessForPrincipal / resolveAccessibleContextId / requireWorkerExports extracted from fetch.ts (no behavior change; resolveAccessibleContextId now takes db instead of the whole RequestContext). The /api/itx door and the new SSR door share one auth boundary, so they cannot drift.
  • src/itx/server.tsgetServerItx(projectSlugOrId, requestContext?): an in-process project-narrowed Itx handle built from the request context (principal from the auth request middleware, env from cloudflare:workers, workerExports from the context), mirroring exactly what fetch.ts does at WebSocket connect time minus the transport. Throws with the kernel's no-existence-probing "not found" wording when the principal may not hold the project.
  • src/itx/loader.tsgetLoaderItx: isomorphic accessor in the orpc/client.ts shape. Server branch dynamically imports server.ts (so cloudflare:workers/db never enter the browser graph even before the Start compiler strips the branch); browser branch reuses the per-tab socket singleton, moved to src/itx/react/browser-client.ts so loaders share the hooks' socket and project-handle cache without importing React. Explicit return types, non-route module — no routeTree Register cycles.
  • prefetchItxQuery (same module): best-effort ensureQueryData that catches and discards every failure. Prefetch is an optimization, never a gate — this is the guard against the prod incident where a FORBIDDEN thrown during route loading crashed the streams page into the generic error boundary. The component's useItxQuery re-surfaces the same error inline.
  • lib/itx-queries.ts restructured: each piece of data has ONE ItxQueryDefinition ({project, queryKey, queryFn, staleTime}) consumed by both the React hook and loader prefetches.
  • Exactly one route wired: the streams index loader seeds the root stream state. Breadcrumbs stay lazy by deliberate choice; they're seeded for free via the shared per-path cache keys.

Note: adaptation to main's drift

The original plan said to prefetch useProjectStreamsList. Since then the streams index was rewritten around StreamTreeBrowser (#1443/#1449), whose first paint gates on the root stream state, not the list — and its inline source used ad-hoc keys (["project", id, "streams", ...]) that didn't share cache with the breadcrumbs' itxKey-based entries. So the loader prefetches projectStreamStateQuery(root), and the route's tree source now keys on itxKey.project(id, "streams") — tree nodes, breadcrumb navigators, and the prefetch all land on projectStreamStateKey(...) per path. useProjectStreamsList keeps its single shared definition.

DECISIONS numbering

The new entry is D18 (next free number at time of writing). If another in-flight PR also claims D18, renumber on merge.

SSR seeding verification

router.tsx wires setupRouterSsrQueryIntegration({ router, queryClient, wrapQueryClient: true }), which dehydrates every query seeded during SSR loaders and hydrates it client-side — the same machinery the established ensureProjectBySlug beforeLoad pattern already relies on. Nothing there needed changing; the loader awaits prefetchItxQuery, so the entry is in the cache before dehydration.

Tests

  • src/itx/loader.test.ts — prefetch seeds the cache through the resolved handle; swallows handle-resolution failures (no principal / forbidden, leaving component-visible error state in cache); swallows kernel queryFn failures; getLoaderItx server-branch wiring.
  • New worker harness src/durable-objects/itx-server-handle.* (pnpm test:itx-server-handle, same family/convention as itx-stream-subscribe.*): hand-built RequestContext (principal + real D1 projects row + ctx.exports) → getServerItxitx.streams.list()/append() against a real Stream Durable Object through the StreamsCapability loopback. Covers member by slug + id, admin, stranger (denied), anonymous (denied), unknown slug (denied) — the insufficient-principal SSR path is exercised here and in the unit suite.
  • pnpm vitest run src/itx/react — 22 tests green (plus loader tests).
  • Repo root: pnpm typecheck && pnpm lint && pnpm format && pnpm test all green.

Docs: D18 in src/itx/DECISIONS.md, SSR section of docs/itx-orpc-replacement-plan.md marked done, tasks/os-orpc-teardown.md checkbox ticked.

🤖 Generated with Claude Code


Note

Medium Risk
Touches authentication boundaries for SSR and /api/itx (shared module) and changes loader error handling; well-covered by new harness and loader tests, but auth drift or silent prefetch failures could affect first paint or error UX.

Overview
Adds SSR/loader prefetch for itx so TanStack routes can seed React Query without opening a WebSocket, restoring the prefetch behavior lost when streams moved off oRPC.

Shared auth boundaryaccess.ts centralizes accessForPrincipal, resolveAccessibleContextId, and requireWorkerExports; /api/itx in fetch.ts and the new SSR path both use it so connect-time and loader-time access cannot drift.

In-process SSR handlegetServerItx builds a project-narrowed Itx from request context via the same resolve chain as WebSocket connect (no capnweb). getLoaderItx is the isomorphic entry (server → dynamic getServerItx; client → shared tab singleton in browser-client.ts). prefetchItxQuery runs ensureQueryData but swallows all errors so forbidden/missing projects do not blow up route loaders (prod streams error-boundary fix).

Query definitionsItxQueryDefinition in lib/itx-queries.ts is shared by hooks and loaders; the streams index loader prefetches root stream state and aligns tree itxKey usage with breadcrumbs.

Tests — Unit tests for loader prefetch contract; new test:itx-server-handle worker harness proving getServerItx → real Stream DO + auth denial cases. Minor e2e timeout/assertion tweaks.

Reviewed by Cursor Bugbot for commit 4a96cca. Bugbot is set up for automated code reviews on this repo. Configure here.

Environment Config Lease

No active environment config lease.

OS

Status: released
Commit: 4a96cca
Preview: https://os.iterate-preview-4.com
Summary: Preview app released.
Workflow run
Updated: 2026-06-10T18:00:52.363Z

Semaphore

Status: released
Commit: 4a96cca
Preview: https://semaphore.iterate-preview-4.com
Summary: Preview app released.
Workflow run
Updated: 2026-06-10T18:00:39.706Z

jonastemplestein and others added 4 commits June 10, 2026 15:39
…etchItxQuery

Converted routes fetched only over the browser itx socket, so every visit
painted a spinner first. This restores loader prefetch for itx-backed
queries (itx DECISIONS D18):

- src/itx/access.ts: accessForPrincipal + resolveAccessibleContextId +
  requireWorkerExports extracted from fetch.ts so the WebSocket door and
  the new SSR door share one auth boundary.
- src/itx/server.ts: getServerItx — in-process project-narrowed handle
  built from the request context (no socket, no capnweb), mirroring the
  /api/itx connect chain.
- src/itx/loader.ts: getLoaderItx (isomorphic, orpc/client.ts shape;
  server branch dynamically imports server.ts, browser branch reuses the
  per-tab socket singleton now living in react/browser-client.ts) and
  prefetchItxQuery, which seeds the QueryClient and swallows all errors —
  prefetch must never crash a route into the error boundary.
- lib/itx-queries.ts: one ItxQueryDefinition per piece of data, consumed
  by both the hooks and loader prefetches so key/fn/staleTime cannot
  drift.
- streams index route: loader seeds the root stream state (the query that
  gates first paint); its tree-browser source now keys on itxKey so tree
  nodes, breadcrumbs, and the prefetch share cache entries per path.
- tests: loader.test.ts (seeding + swallow contract) and a new
  itx-server-handle worker harness proving getServerItx against a real
  Stream DO via the capability loopback, including denied principals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Worker-harness entry/config files are reachable only through their
pnpm script, so knip needs them declared like the sibling families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/os/src/itx/server.ts
jonastemplestein and others added 2 commits June 10, 2026 15:53
An unauthenticated SSR render threw "not found. (Unauthenticated
request.)" — wording that distinguishes auth state from denial and so
breaks the kernel's no-probing posture (bugbot on #1457). Both denial
paths now throw byte-identical ItxError NOT_FOUND, which also gives the
SSR door the same coded errors as the socket door.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/os/src/itx/loader.ts
capnweb's receiver rebuilds a plain Error — custom names and class
identity never survive the wire, which is exactly why D18 makes
detection duck-typed. The e2e contradicted its own doc comment by
asserting error.name === "ItxError"; it now asserts getItxErrorCode +
details, the actual contract. (This assertion also failed inside PR
#1456's Preview e2e job, which nevertheless reported success — the
preview runner swallowing a suite failure is a separate problem,
recorded in the PR thread.)

Also gives the two-event /api/itx/run record test 90s: the cold first
run of isolate + stream DO on a fresh preview blew the 45s default and
passed on the in-job retry at 12s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3928b28. Configure here.

Comment thread apps/os/src/itx/e2e/itx.e2e.test.ts
jonastemplestein and others added 4 commits June 10, 2026 16:34
The preview e2e caught a real regression shipped in #1456: capnweb's
receiver rebuilds `new Error(message)` and never assigns the custom
name (verified in the 0.8.0 deserializer and against a live preview),
so the `name === "ItxError"` gate rejected every wire-crossed kernel
error — getItxErrorCode returned undefined, access errors were retried
again, and the access-denied UI never fired. The unit suite missed it
because its simulated crossing unfaithfully copied the name.

Detection is now code-only (the five-code set; foreign code strings
like ENOENT are outside it), the simulation matches the real receiver
(name comes back "Error" and a test pins that), and the doc comments
record why the name must not participate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main reverted the descendantPaths catalog (CORE_STATE_VERSION 3) and
deleted ItxStreams.list() — the explorer walks childPaths level by
level instead. The loader-prefetch work keeps its shape: the streams
index still seeds the root stream state through the shared
projectStreamStateQuery definition (now parsing StreamNavigationState
per main), and the server-handle harness proves access via the root's
childPaths rather than the deleted list().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jonastemplestein jonastemplestein merged commit 13feb7a into main Jun 10, 2026
7 checks passed
@jonastemplestein jonastemplestein deleted the itx-server-handle branch June 10, 2026 17:58
jonastemplestein added a commit that referenced this pull request Jun 10, 2026
Streams index route keeps main's projectStreamStateQuery/prefetch
structure; the query factory's type moves from the deleted
StreamNavigationState to StreamState (same shape now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jonastemplestein added a commit that referenced this pull request Jun 10, 2026
Follow-up to #1457, addressing bugbot's "failed prefetch poisons query
cache" finding: a swallowed prefetch failure left an errored, data-less
entry on the shared query key, so the consuming component (streams tree,
breadcrumb navigators) flashed its error state on mount before
`retryOnMount` refetched. The catch now removes the entry when it is
errored **and** empty — components mount pending and fetch fresh.
Entries that already hold data are kept (stale data + background error
beats no data), covered by a new revalidation test.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Narrow loader/cache hygiene change with matching unit tests; no auth
or API surface changes.
> 
> **Overview**
> Fixes a **prefetch cache poisoning** issue: when `prefetchItxQuery`
swallowed a failure on a **first** fetch, React Query still left an
**errored, data-less** entry on the shared key, so streams tree /
breadcrumb UI briefly showed error on mount before `retryOnMount`
refetched.
> 
> After the catch, the loader now **`removeQueries`** when state is
`error` and `data` is undefined, so consumers mount **pending** and
fetch fresh. **Stale entries with existing data are untouched** when
revalidation fails—stale data is preferred over wiping the cache.
> 
> Tests now assert no cache residue on failed prefetch and add coverage
for failed revalidation with seeded data.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3219b15. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- CLOUDFLARE_PREVIEW -->
## Environment Config Lease
<!-- CLOUDFLARE_PREVIEW_STATE -->
<!--
{
  "apps": {
    "os": {
      "appDisplayName": "OS",
      "appSlug": "os",
      "status": "deployed",
      "updatedAt": "2026-06-10T18:06:42.920Z",
      "headSha": "3219b1530a952cd1e70eaefb2d670358443f17cd",
      "message": null,
      "publicUrl": "https://os.iterate-preview-4.com",
"runUrl": "https://github.com/iterate/iterate/actions/runs/27295793774",
      "shortSha": "3219b15"
    }
  },
  "environmentConfigLease": {
    "dopplerConfig": "preview_4",
    "leasedUntil": 1781118146120,
    "leaseId": "8496eb91-1658-4e35-bde7-707083605ac3",
    "slug": "preview-4",
    "type": "environment-config-lease"
  }
}
-->
<!-- /CLOUDFLARE_PREVIEW_STATE -->
Lease: `preview-4`
Doppler config: `preview_4`
Type: `environment-config-lease`
Leased until: 2026-06-10T19:02:26.120Z

### OS
Status: deployed
Commit: `3219b15`
Preview: https://os.iterate-preview-4.com
[Workflow
run](https://github.com/iterate/iterate/actions/runs/27295793774)
Updated: 2026-06-10T18:06:42.920Z
<!-- /CLOUDFLARE_PREVIEW -->

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jonastemplestein added a commit that referenced this pull request Jun 10, 2026
#1479)

## Streams review — Stages 4–7 (browser robustness, perf, dead code,
docs)

The final batch of the `packages/streams` adversarial review
(`tasks/streams-review-fixes.md`), bundled into one PR per request.
Stages 0/1/3 already shipped (#1455, #1458, #1459); **Stage 2 (lazy
init) was abandoned** — #1460's subscriber-presence model appends a
`subscriber-connected` fact on every `subscribe()`, which fundamentally
conflicts with "don't initialize storage until the first `append()`".
`created`/`woken` stay eager.

All findings were re-verified against current `main` after #1460/#1457
moved the codebase; items those PRs already fixed or made obsolete are
called out below.

### Stage 4 — browser-runtime correctness
- **C1-browser** — the server delivery pump is fire-and-forget for
inbound subscribers and never reports a failed `ingest`, so a browser
mirror that fails to apply a batch silently desyncs forever. The browser
now self-heals: `ingestWithSelfHeal` resubscribes from the persisted
checkpoint with bounded exponential backoff on ingest failure.
- **B1** — connection-epoch guard so a stale connection's late status
callback can't clobber the live connection.
- **B2** — `appendBatch`/`runtimeState` await connection readiness
(`whenStreamReady`) instead of throwing "disposed" during a transient
reconnect; only throw when actually disposed.
- **B3** — the `ROLLBACK` in `stream-db.worker.ts` `batch()` is now in
its own try/catch so it can't mask the original error (which
`withBusyRetry`/`isBusyError` need to see).
- **B4** — reconcile guards on stream incarnation (server `createdAt` +
a `mirror_meta` table): rebuild the mirror on incarnation change rather
than trusting the offset comparison after a `reset()`.
- **B5** — `AbortSignal` on the Web Lock request (released on disposal)
+ the request rejection is surfaced instead of `void`-swallowed.
- **B6** — query lifecycle: arm GC at query creation (not only on
unsubscribe), skip listenerless queries in `#onChange`, and
equality-check before notifying to avoid spurious `useSyncExternalStore`
churn.

### Stage 5 — performance
- **P1** — `browser-event-feed` O(n²) write amplification: coalesce to
one op per `local_index` (was a cumulative op per event, each
re-serializing the whole accumulated array).
- **P2** — bound group rows with `MAX_GROUP_EVENTS = 200` so one
dominant event type can't grow a single blob unboundedly.
- **P3** — drop the per-event full-state `stateSchema.parse` in core
`reduce`; state is validated only at the KV/recovery trust boundary now.
- **P4** — already fixed by #1460 (subscribe override forwards
`eventTypes`; filtering is server-side). No change.

### Stage 6 — dead code / elegance (~247 lines deleted)
- **E1** dead exports in `shared/stream-processors.ts`; **E2**
`waitForOpen`; **E3** unreachable circuit-breaker `paused` guard; **E5**
dead `waitForEvent`/`messageInbox.error` machinery in `subscription.ts`;
**E6** redundant circuit-breaker `consumes` entries.
- **E4 obsolete** (#1460 removed `processor-registered`; the shared
`circuit-breaker-types` is a live, separately-imported hierarchy, not
dead code). **E7 declined** — post-#1460 the subscribe override
genuinely needs to retain the client callback / wire `onRpcBroken`, so
folding it into the generic `makeRpcTargetClass` isn't worth it.

### Stage 7 — docs
- **D1** `design.md`: status banner, fixed 1-based offsets + core-event
examples, the callable subscriber spec, SQLite/512KB-chunking storage,
live-tail `replayAfterOffset` default, OPFS mirror; superseded banners
on the never-shipped `implementProcessor`/`connectStream` sections.
- **D2** `README.md`: real browser export
(`withStreamConnectionFromBrowser`), sync `Disposable`, `?path=` route
map; new "Append & subscription semantics" section (D5).
- **D3** ADR 0001 superseded with the shipped per-`(namespace, path,
slug)` singleton model.
- **D4** remaining comment drift (`beforeAppend`→`validateAppend`,
`afterAppendBatch`→`processEventBatch`).

### Testing
`pnpm typecheck`, `pnpm lint`, node (79) + workers (15) suites, and
example-app typecheck all green. Example-app Playwright e2e run locally
against a clean `vite dev` (real Stream DO via Miniflare): **26
passed**. `origin/main` merged in (no `packages/streams` overlap; the
new project-config stream-processor consumers don't touch any deleted
exports).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches browser reconnect, mirror discard, and ingest self-heal paths
that affect live viewers and local SQLite mirrors; server stream DO
behavior is mostly unchanged aside from core reduce hot-path
optimization.
> 
> **Overview**
> Closes out **Stages 4–7** of the `packages/streams` adversarial
review: browser mirror reliability, feed/core hot-path perf, dead-code
removal, and doc alignment. Stage 2 (lazy stream init) stays
**abandoned** and is only noted in `tasks/streams-review-fixes.md`.
> 
> **Browser runtime** (`stream-browser-store.ts` and friends) is
reworked so the OPFS mirror can recover instead of silently drifting.
Inbound delivery is fire-and-forget, so failed `ingest` now triggers
**`ingestWithSelfHeal`** (resubscribe from the last checkpoint with
capped exponential backoff). Reconnects go through one
**`scheduleReconnect`** path with a **connection epoch** so stale
WebSocket callbacks cannot tear down the replacement connection.
**`appendBatch` / `runtimeState`** use **`callWhenReady`** /
**`whenStreamReady`** during transient reconnects instead of throwing
“disposed”. Mirror trust uses server **`createdAt`** as incarnation
identity via new **`mirror_meta`** helpers in
**`stream-browser-db.ts`**. Web Lock **`release()`** aborts pending lock
requests; the SQLite worker preserves the original batch error if
`ROLLBACK` fails; reactive queries GC earlier, skip unobserved
refreshes, and avoid notify when snapshots are unchanged.
> 
> **Performance:** `browser-event-feed` **`planFeedOps`** coalesces one
SQL op per group row and caps groups at **`MAX_GROUP_EVENTS` (200)**.
Core **`reduce`** no longer exit-parses the full `stateSchema` on every
appended event.
> 
> **Cleanup:** ~247 lines removed (unused `stream-processors` exports,
`waitForOpen`, dead subscription `waitForEvent` machinery, trimmed
circuit-breaker `consumes` and an unreachable guard). **Docs:** `README`
(real browser API, `?path=` routes, append/subscription semantics),
`design.md` and ADR 0001 marked superseded where they diverge from
shipped code.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f52f305. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- CLOUDFLARE_PREVIEW -->
## Environment Config Lease
<!-- CLOUDFLARE_PREVIEW_STATE -->
<!--
{
  "apps": {
    "os": {
      "appDisplayName": "OS",
      "appSlug": "os",
      "status": "deployed",
      "updatedAt": "2026-06-10T22:02:58.860Z",
      "headSha": "f52f305a9f1ab43c9749f80e30bcd96705cbce59",
      "message": null,
      "publicUrl": "https://os.iterate-preview-3.com",
"runUrl": "https://github.com/iterate/iterate/actions/runs/27309066809",
      "shortSha": "f52f305"
    }
  },
  "environmentConfigLease": {
    "dopplerConfig": "preview_3",
    "leasedUntil": 1781132382486,
    "leaseId": "52648479-bd91-469d-91e5-a3338534feae",
    "slug": "preview-3",
    "type": "environment-config-lease"
  }
}
-->
<!-- /CLOUDFLARE_PREVIEW_STATE -->
Lease: `preview-3`
Doppler config: `preview_3`
Type: `environment-config-lease`
Leased until: 2026-06-10T22:59:42.486Z

### OS
Status: deployed
Commit: `f52f305`
Preview: https://os.iterate-preview-3.com
[Workflow
run](https://github.com/iterate/iterate/actions/runs/27309066809)
Updated: 2026-06-10T22:02:58.860Z
<!-- /CLOUDFLARE_PREVIEW -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant