npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rxjs-fullstack

v1.0.0

Published

<p align="center"> <img src="./assets/rxjs-fullstack-logo.webp" alt="RxJS Fullstack logo" width="360" /> </p>

Readme

RxJS Fullstack

rxjs-fullstack is an experiment in a minimal fullstack web framework whose application execution model is RxJS.

The framework deliberately leaves existing technologies in charge of the jobs they already solve:

  • TypeScript — language, strong typing, and JSX compilation.
  • RxJS 7 — lazy dataflows, state, effects, cancellation, sharing, and Query/Cache.
  • TypeScript JSX — view syntax without React.
  • rxjs-router — strongly typed route matching, navigation, request resolution, and route data.
  • Hono — Web-API HTTP layer.
  • Bun — reference development runtime and build tool.
  • Node.js / fetch-native edge runtimes — alternate runtime hosts around the same Web Request → Response application boundary.

The central rule is: RxJS is the application machine; the surrounding technologies remain thin, explicit boundaries.

This README is the canonical source for the rxjs-fullstack project page. Every milestone should therefore document not only what was implemented, but why it exists, what flows through it, where execution starts, how cancellation works, which technology owns each responsibility, and how the milestone is verified.

Milestone status

M01  TypeScript JSX runtime                         ✅
M02  RxJS DOM bindings                             ✅
M03  Pure HTML renderer + basic SSR               ✅
M04  Hono + Bun server                             ✅
M05  Strongly typed routing + Query/Cache         ✅
M06  File-based route discovery                   ✅
M07  Forms + RxJS server actions                  ✅
M08  SSR Query Prefetch / Client Data Continuity  ✅
M09  Static Site Generation                       ✅
M10  Runtime Adapters                             ✅
M11  Database Integration                         ✅
M12  Authentication                               ✅
M13  Streaming / Advanced SSR                     ✅

M01 — TypeScript JSX runtime

M01 establishes a framework-owned view representation without React.

TypeScript compiles TSX through the automatic JSX runtime (jsx: "react-jsx" with jsxImportSource pointing at the framework's own src/jsx/jsx-runtime.ts — the setting's name is historical; React is not involved). The compiler injects the runtime import itself, so view modules never import a JSX factory manually, and fragments (<>...</>) type-check. JSX therefore produces ViewChild values rather than DOM nodes or React elements.

The intrinsic JSX surface is deliberately typed: standard HTML tag names are accepted, common attributes are explicit, and data-* / aria-* remain extensibility points, so misspelled tags or unsupported attributes fail typechecking. The runtime supports primitives, intrinsic elements, fragments, function components, nested children, and RxJS Observables as live view values. It does not render, subscribe, create DOM, or own lifecycle.

The implementation lives in src/jsx/runtime.ts.

M02 — RxJS DOM bindings

M02 interprets the same ViewChild representation in the browser.

mount(view, container) returns the RxJS Subscription that owns the mounted view's lifetime. Observable children and attributes are subscribed only when the view is mounted. Unsubscribing tears down event listeners, child subscriptions, live regions, and the mounted DOM.

DOM events flow into RxJS through Observers:

<button on={{ click: click$ }}>...</button>

The renderer only forwards event packages. Application meaning remains in the RxJS pipeline.

Live form state is the one exception to attribute binding: value, checked, and selected are assigned through the DOM property, because their attributes only set a control's default state and stop reflecting once the user has interacted with it. Everything else binds as an attribute.

Errors follow the same ownership rule: recovery belongs in the application dataflow (catchError), not in the renderer. If an Observable child or attribute binding errors anyway, the renderer tears the binding down — the live region is cleared, a failed attribute is removed — and reports the error through mount()'s optional onError hook. By default the error is rethrown as an unhandled error, so failures stay loud instead of freezing stale DOM.

The implementation lives in src/render/dom.ts. Its invariants have their own executable verification (scripts/verify-dom.tsx), which runs the renderer against a real DOM implementation: events dispatched on rendered elements reach their Observers and drive the counter state machine, every live-region emission tears down the previous child's subscription before the replacement renders, replaced children lose their event listeners, unsubscribing the mount() Subscription removes the DOM and closes every Observable binding, and erroring bindings clear their region or attribute and reach the onError hook.

M03 — Pure HTML renderer and SSR

M03 adds the server interpreter for the M01 view representation.

renderToString() is deliberately synchronous and pure. It renders already-resolved JSX values to HTML and never subscribes to an Observable. Observable children or attributes reaching the HTML renderer are rejected with a TypeError.

Server asynchronous work must therefore resolve before rendering:

request
  ↓
route/request dataflow
  ↓
resolved model
  ↓
JSX
  ↓
renderToString()
  ↓
HTML

This keeps execution, cancellation, and subscription policy in RxJS rather than hiding them in rendering.

The implementation lives in src/render/html.ts.

M04 — Hono + Bun server

M04 completes the first full server path.

Hono owns HTTP. Bun is the first reference runtime adapter. src/server/app.tsx defines the Web-API application, while the Bun entry supplies the runtime fetch host and port.

The server path is:

HTTP request
  ↓
runtime host
  ↓
Hono
  ↓
RxJS/router data resolution
  ↓
resolved page data
  ↓
JSX + pure HTML rendering
  ↓
Response

No framework semantics depend on Bun-specific APIs. M10 later makes that architectural intention executable across multiple runtimes.

M05 — Strongly typed routing and Query/Cache

M05 adds reusable application infrastructure.

The running application uses the sibling rxjs-router package on both server and browser:

  • server: resolveRequest({ routes, request }),
  • browser: createRouter({ routes, history }),
  • browser view state: router.state$.

An earlier standalone typed-route experiment in src/router/ proved the routing type model with path-literal-derived params and typed href(...) generation. It has since been removed: rxjs-router itself provides the same guarantees through PathParams inference and typed buildPath(...) URL construction, and the dynamic /hello/$name page now lives in the application route tree as an ordinary discovered route module.

M05 also internalizes the Query/Cache layer under src/query/. Queries are Observables, mutations expose cold mutate$() streams, invalidation/refetching stays explicit, and the Todos vertical slice proves browser Query/Cache against a Hono API.

M06 — File-based route discovery

M06 removes central manual registration of page route modules.

Page modules live in:

src/routes/*.tsx

Each page module default-exports its rxjs-router route object. scripts/generate-routes.ts discovers the modules with Bun.Glob, sorts them deterministically, and writes the complete strongly typed root tree to src/routes.generated.ts.

src/routes/*.tsx
      ↓
Bun.Glob discovery
      ↓
sorted imports
      ↓
generated createRoute({ children: [...] })
      ↓
src/routes.generated.ts
      ↓
src/routes.tsx public re-export

The generator only discovers and assembles modules. rxjs-router still owns route semantics: matching, params, loaders, navigation, redirects, cancellation, and request resolution.

Generating the root createRoute() call with its inline children tuple preserves TypeScript's exact route-tree inference.

/about is the M06 proof route: it participates in SSR without being manually imported into a central route registry.

M07 — Forms + RxJS server actions

M07 is the point where rxjs-fullstack moves from framework structure into a complete fullstack effect.

Before M07, the Todos example could already fetch cached server data and perform a mutation, but the write path was still an application-specific HTTP workflow: a button click triggered code that queried the DOM for an input element and posted directly to POST /api/todos.

M07 replaces that path with a reusable framework model:

HTML form
   ↓
SubmitEvent
   ↓
RxJS submit dataflow
   ↓
typed ServerActionRef<Input, Output>
   ↓
cold/cancellable HTTP Observable
   ↓
Hono server-action boundary
   ↓
lazy RxJS server execution
   ↓
pure domain validation + operation
   ↓
typed result
   ↓
Query/Cache invalidation
   ↓
refetched view

The result is the first end-to-end proof that UI events, concurrency policy, network effects, server execution, and data refresh can all remain one explicit RxJS-oriented architecture without introducing a component framework lifecycle or an eager Promise-based action abstraction.

The form itself is the event source

The Todos UI now declares an ordinary HTML form whose submit event is forwarded to an RxJS Subject<SubmitEvent>:

const submit$ = new Subject<SubmitEvent>();

<form on={{ submit: submit$ }}>
  <input
    name="title"
    type="text"
    placeholder="What needs doing?"
    required
  />
  <button type="submit">Add</button>
</form>

This matters because the user intent is submit this form, not click this particular button. Keyboard submission and button submission therefore enter the same source stream.

The DOM renderer still knows nothing about Todos or forms. Its responsibility remains the M02 rule: take a browser event and forward that event package to an Observer. The application decides what the event means.

The browser-default navigation is stopped by an ordinary function used inside the pipeline:

const preventFormNavigation = (event: SubmitEvent): void => {
  event.preventDefault();
};

The operator remains visible:

submit$.pipe(
  tap(preventFormNavigation),
  ...
);

This follows the project's FP/RxJS rule: name the domain or application function; do not rename the RxJS mechanism around it.

FormData is converted into domain data before the effect

The form element is available as event.currentTarget. M07 reads FormData from that exact submitting form instead of searching global DOM state.

Conceptually:

SubmitEvent
    ↓
currentTarget: HTMLFormElement
    ↓
FormData(form)
    ↓
raw title string
    ↓
createTodoInput(title)
    ↓
CreateTodoInput

createTodoInput() lives in src/domain/todos.ts. It trims the title and returns a typed CreateTodoInput only for a non-empty value.

The important boundary is:

browser representation      domain representation
FormData / string      →     CreateTodoInput

After that conversion, the server-action transport moves a typed package. It does not care what a Todo title means.

The submit concurrency policy is explicit

The central browser pipeline is:

const status$ = submit$.pipe(
  tap(preventFormNavigation),
  exhaustMap((event) => {
    const submission = readTodoSubmission(event);
    // ... invoke action, invalidate query, reset form
  }),
  startWith(''),
);

exhaustMap is deliberately visible because it states the form's concurrency policy:

While one Todo creation is in flight, ignore additional submissions.

In practical terms:

time ─────────────────────────────────────────►

submit     A────B────C────────────D
           │    ×    ×             │
           │                       │
           ▼                       ▼
action     [ save A ........ ]     [ save D ... ]

policy     exhaustMap = ignore while busy

Nothing in the server-action API chooses this for the application. Another form can choose a different policy without changing the transport:

mergeMap   = allow overlapping submissions
switchMap  = cancel/replace with the latest submission
concatMap  = queue submissions
exhaustMap = ignore submissions while one is active

This is a central design decision for rxjs-fullstack: the effect describes how to execute one request; the RxJS pipeline describes how multiple requests relate over time.

Shared server-action references are contracts, not implementations

The client and server need to agree that an action exists and what TypeScript values it carries, but the browser must not import server implementation code.

M07 therefore introduces ServerActionRef<TInput, TOutput>.

The Todo action declaration is intentionally small:

export const createTodoAction = defineServerAction<CreateTodoInput, Todo>(
  'todos.create',
);

It provides three things:

action identity     todos.create
input type          CreateTodoInput
output type         Todo

It does not contain:

  • Todo persistence,
  • validation implementation,
  • Hono code,
  • server-only dependencies,
  • query invalidation,
  • concurrency policy.

This makes the action reference safe to share with browser code while keeping the server implementation physically and conceptually separate.

The shared primitives live in:

src/actions/action.ts
src/actions/todos.ts

Client action execution is cold and cancellable

The browser invokes the action through:

invokeServerAction$(createTodoAction, input)

invokeServerAction$() returns an RxJS Observable backed by fromFetch.

That gives the client action the same execution model as the rest of the framework:

Observable exists
      │
      │ no subscription
      ▼
nothing happens

subscribe
      ↓
HTTP POST starts
      ↓
response arrives
      ↓
Todo emitted
      ↓
complete

The action URL is derived from the action identity:

todos.create
     ↓
/api/actions/todos.create

The request body contains the typed input serialized as JSON.

This transport remains intentionally small. It does not know about forms, Todo state, exhaustMap, or Query/Cache. It performs one job:

ServerActionRef<Input, Output> + Input
                ↓
        Observable<Output>

Cancellation follows the RxJS Subscription

Because the action request is a fromFetch Observable, unsubscription aborts the underlying request.

The cancellation chain is therefore explicit:

mount(TodoApp)
    ↓
view Subscription
    ↓
status$ subscription
    ↓
active createTodo$ subscription
    ↓
fromFetch request

If the mounted view is torn down:

view lifetime.unsubscribe()
        ↓
status$ unsubscribes
        ↓
active inner action unsubscribes
        ↓
fromFetch aborts HTTP request

M07 therefore does not introduce a second cancellation system. It extends the M02 lifetime rule across the network boundary: the RxJS Subscription remains the owner of browser work.

The server action is also a lazy RxJS dataflow

The server side mirrors the browser-side execution model.

registerServerAction() connects a shared ServerActionRef to a server-only handler. The handler contract separates input parsing from execution:

interface ServerActionHandler<TInput, TOutput> {
  parse(value: unknown): TInput;
  run(
    input: TInput,
    context: ServerActionContext,
  ): Observable<TOutput>;
  successStatus?: number;
}

The HTTP adapter first obtains raw JSON. The typed server execution is then described by executeServerAction$():

raw unknown JSON
      ↓
executeServerAction$()
      ↓
defer(...)
      ↓
handler.parse(rawInput)
      ↓
typed TInput
      ↓
handler.run(input, context)
      ↓
Observable<TOutput>

Both parsing and handler invocation are inside defer(). That means they do not run merely because the Observable was constructed.

At the HTTP boundary, Hono consumes that lazy description with firstValueFrom() and converts the result into a JSON Response.

Hono owns HTTP
       ↓
firstValueFrom(executeServerAction$(...))
       ↓
RxJS owns action execution
       ↓
Hono owns HTTP response

This is the same boundary pattern already established for server rendering: asynchronous execution happens before the pure boundary consumes its resolved value.

Raw input is validated again on the server

TypeScript types disappear at the network boundary, so a typed client declaration is not sufficient runtime validation.

The server receives unknown and calls the domain parser:

parseCreateTodoInput(value)

The flow is:

JSON payload
  unknown
    ↓
parseCreateTodoInput
    ↓
CreateTodoInput | undefined
    ↓
valid input ──────────────► run action
invalid input ────────────► HTTP 400

This means the action reference gives compile-time agreement while the domain parser gives runtime trust at the server boundary.

The validation rule itself still lives in the domain layer, not in Hono and not in the generic action adapter.

Server context carries request lifetime information

A server action receives:

interface ServerActionContext {
  request: Request;
  signal: AbortSignal;
}

The Todo action checks signal.aborted before performing the in-memory write. This establishes the first server-action cancellation hook and leaves room for later database or network effects to consume the same request signal.

M07 does not yet claim that every possible server-side side effect is automatically cancellable. What it establishes is the correct architecture: the request AbortSignal is available to the RxJS server action instead of being hidden by the HTTP adapter.

Todo business logic remains outside the action framework

The server-specific Todo store lives in src/server/todos-store.ts. The generic action adapter does not know its structure.

The Todo action performs this conceptual work:

CreateTodoInput
      ↓
addTodo(input)
      ↓
Todo

The RxJS layer merely lifts that operation into a lazy execution description:

defer(() => of(addTodo(input)))

This preserves the project's rule:

Pure/domain logic inside; RxJS and HTTP plumbing outside.

The domain can later change from an in-memory array to a database without changing the form source, the exhaustMap policy, the action reference, or the basic action transport.

Query/Cache owns the read side after a successful write

M07 deliberately does not make the server action secretly update browser state.

The action returns the created Todo. The application then explicitly invalidates the existing Todos query:

createTodo$(input)
      ↓
Todo emitted
      ↓
invalidateQueries(['todos'])
      ↓
active GET /api/todos refetch
      ↓
query result emits
      ↓
list$ renders latest data

The relevant pipeline uses concatMap because the status should not become Saved. until invalidation/refetch work has completed:

createTodo$(submission.input).pipe(
  concatMap(() =>
    queryClient.invalidateQueries({ queryKey: todosQuery.queryKey }),
  ),
  tap(() => submission.form.reset()),
  map(() => 'Saved.'),
  startWith('Saving...'),
);

This gives each mechanism one responsibility:

| Concern | Owner | | --- | --- | | form event | browser / JSX Observer binding | | temporal submit policy | exhaustMap | | domain input creation | createTodoInput() | | client action transport | invokeServerAction$() / fromFetch | | HTTP routing and response | Hono | | runtime input validation | parseCreateTodoInput() | | Todo creation | addTodo() | | server execution | executeServerAction$() + handler Observable | | cached server reads | Query/Cache | | post-write refresh | explicit invalidateQueries() | | browser lifetime/cancellation | RxJS Subscription |

No single layer becomes a hidden mini-framework.

The M07 Todo write path versus the read path

M07 now gives the Todos feature two intentionally different fullstack paths.

Read path:

TodoApp subscription
      ↓
queryClient.query$(todosQuery)
      ↓
GET /api/todos
      ↓
readonly Todo[]
      ↓
Query/Cache
      ↓
list$ view

Write path:

<form submit>
      ↓
submit$
      ↓
exhaustMap
      ↓
createTodo$(CreateTodoInput)
      ↓
POST /api/actions/todos.create
      ↓
server action
      ↓
Todo
      ↓
invalidate ['todos']
      ↓
read path refetches

This separation is useful: actions perform effects; queries describe cached server reads. They coordinate explicitly rather than being fused into one opaque abstraction.

M07 failure behavior is explicit

The action boundary distinguishes several failure classes:

malformed JSON
    ↓
HTTP 400

valid JSON shape but invalid domain input
    ↓
ServerActionInputError
    ↓
HTTP 400

unexpected server handler failure
    ↓
HTTP 500

non-2xx browser response
    ↓
ServerActionRequestError
    ↓
Observable error
    ↓
catchError in Todo pipeline
    ↓
"Failed to save."

The error therefore travels through the Observable error channel until the application decides how to turn it into view state.

M07 source map

The milestone is intentionally spread across small responsibility-focused files:

src/domain/todos.ts
  Todo and CreateTodoInput types
  createTodoInput()
  parseCreateTodoInput()

src/actions/action.ts
  ServerActionRef<Input, Output>
  defineServerAction()
  serverActionHref()
  invokeServerAction$()
  ServerActionRequestError

src/actions/todos.ts
  shared createTodoAction reference

src/queries/todos.ts
  todosQuery read definition
  createTodo$ client action invocation

src/examples/todos.tsx
  form source
  SubmitEvent → FormData → CreateTodoInput
  exhaustMap submit policy
  Query/Cache invalidation
  status rendering

src/server/action.ts
  ServerActionHandler contract
  executeServerAction$()
  registerServerAction()
  HTTP/error translation

src/server/api.ts
  GET /todos
  createTodoAction server registration

src/server/todos-store.ts
  in-memory Todo state and addTodo()

This file layout is part of the design. Shared action identity, browser transport, generic server adapter, domain rules, application orchestration, and server storage do not collapse into one module.

M07 verification

scripts/verify.tsx treats the milestone as an executable architectural specification. It checks that:

  • server-action input parsing does not run before subscription,
  • the server handler does not run before subscription,
  • one subscription executes the action exactly once,
  • valid Todo action input returns HTTP 201,
  • the returned object contains the created Todo,
  • created data becomes visible through the existing GET /api/todos read path,
  • empty/invalid Todo input returns HTTP 400,
  • the old ad-hoc POST /api/todos mutation endpoint is no longer the write path,
  • the browser examples still typecheck and bundle successfully.

The milestone is therefore not considered complete merely because the Todo UI works. The verification also protects the intended execution semantics: laziness, typed boundaries, HTTP behavior, and separation of the old mutation route from the new server-action architecture.

What M07 establishes

M07 adds one important capability, but more importantly it establishes a reusable execution pattern for later fullstack features:

source event
    ↓
plain function extracts domain value
    ↓
RxJS operator chooses temporal policy
    ↓
cold effect Observable
    ↓
server RxJS dataflow
    ↓
domain operation
    ↓
result / error
    ↓
explicit state or cache update

A future login form, checkout command, settings save, file metadata update, or database mutation can use the same machine while changing only the domain packages and the chosen concurrency policy.

That is the larger M07 result: server actions are not a new execution model added beside RxJS; they are one more effect that participates in the existing RxJS machine.

M08 — SSR Query Prefetch / Client Data Continuity

M08 closes the read-side discontinuity that remained after M05–M07.

Before M08, /todos had two separate starts:

server request                     browser mount
     ↓                                  ↓
SSR route                           TodoApp subscribes
     ↓                                  ↓
"Loading todos..."                 Query/Cache is empty
                                        ↓
                                   GET /api/todos
                                        ↓
                                   render Todos

The server knew how to render the page shell, and the browser knew how to fetch Todos, but server-resolved data was not carried across the HTML boundary. The browser therefore had to cold-start the same read again.

M08 changes that flow to:

HTTP GET /todos
      ↓
request-scoped QueryClient
      ↓
rxjs-router loader
      ↓
queryClient.query$(todosQuery)
      ↓
in-process GET /api/todos
      ↓
readonly Todo[]
      ↓
Query/Cache
      ├──────────────► static SSR Todo snapshot
      │
      └──────────────► dehydrate()
                         ↓
                  application/json bootstrap state
                         ↓
                       HTML
                         ↓
browser entry reads state before mount
      ↓
hydrate(queryClient, state)
      ↓
TodoApp subscribes to the same query key
      ↓
fresh cached Todos emit immediately

The important result is not merely that the server can fetch Todos. The important result is continuity of the same query state across the server/browser boundary.

The QueryClient is request-scoped on the server

The server must never reuse one application-wide QueryClient across users or requests. M08 therefore creates a new QueryClient inside each Hono page request:

const queryClient = new QueryClient();

That client is passed into rxjs-router through its explicit route context:

resolveRequest({
  routes,
  request,
  context: {
    queryClient,
    fetch: routeFetch,
  },
});

This preserves the route architecture established in M06. Hono does not gain a special if (pathname === '/todos') prefetch branch. The route loader declares the data it needs; Hono only supplies request infrastructure.

Request isolation is therefore:

request A ──► QueryClient A ──► dehydrate A ──► response A
request B ──► QueryClient B ──► dehydrate B ──► response B

No query state crosses between requests unless an application later introduces an explicit shared server cache.

The Todos route owns its SSR query requirement

src/routes/todos.tsx now uses the QueryClient from router context while resolving the page.

The server loader subscribes to:

context.queryClient.query$(createTodosQuery(context.fetch))

and waits until the query has either data or an error before creating the pure SSR view.

The route request AbortSignal is connected with takeUntil(...). If the request is cancelled while the query is active, the route subscription is torn down rather than leaving a detached page-read subscription running.

Conceptually:

route request signal ───────────────┐
                                    │
query$ ── loading ── data ── ...    │
  │                                 │
  └──────── takeUntil(abort) ◄──────┘

The request controls the lifetime of the server read.

Server and browser use the same query identity

M08 keeps the existing query key:

['todos']

createTodosQuery(fetcher) makes only the transport replaceable. The browser uses the normal Web fetch; the server receives an in-process Hono fetch adapter.

The query contract remains:

query key        ['todos']
result           readonly Todo[]
HTTP contract    GET /api/todos

The server adapter resolves /api/todos through the same Hono application without performing a real network round-trip. This is important because M08 does not introduce a second Todo read implementation just for SSR.

The transport can change; the query identity and data package do not.

SSR renders resolved data, not an Observable

M03's renderer rule remains unchanged: renderToString() never subscribes.

M08 resolves the query before rendering and then builds a static TodoSnapshot from the resulting readonly Todo[]:

query$ subscription
      ↓
readonly Todo[]
      ↓
TodoSnapshot
      ↓
ViewChild containing plain values
      ↓
renderToString()

The SSR renderer still receives no live Observable child.

This means M08 extends SSR without weakening the M03 boundary. Asynchronous execution remains outside the pure HTML renderer.

Query state is dehydrated into the HTML document

After routing has finished, the request-scoped QueryClient contains the successful Todos query. The server calls:

dehydrate(queryClient)

and emits the result into a JSON bootstrap script:

<script id="rxjs-query-state" type="application/json">...</script>

renderDocument() now supports generic JSON bootstrap scripts and performs HTML-safe JSON escaping. In particular, <, >, and & are encoded so query data cannot accidentally terminate the script element and become executable HTML.

The bootstrap element is data, not JavaScript code. No query logic is embedded in the document.

The browser restores Query/Cache before TodoApp subscribes

src/examples/todos-client.tsx now performs the bootstrap in this order:

find #app
   ↓
read #rxjs-query-state
   ↓
JSON.parse
   ↓
hydrate(queryClient, state)
   ↓
mount(TodoApp)
   ↓
TodoApp subscribes to queryClient.query$(todosQuery)

The ordering is essential.

If TodoApp subscribed first, its empty client QueryClient could begin a browser fetch before the server state was restored. Hydrating first means the first browser subscription sees the server-populated cache.

The helper hydrateQueryClientFromDocument() performs only this bootstrap boundary. It does not mount UI or choose query behavior.

Freshness policy prevents the immediate second cold fetch

Hydration alone is not enough.

A query with staleTime: 0 is stale immediately. The client could correctly restore the server value and then immediately refetch it because the normal query policy says the data is stale.

M08 therefore makes freshness explicit for Todos:

staleTime: 30_000

The temporal behavior is now:

time ─────────────────────────────────────────────►

server fetch       ● data resolved
                   │
HTML response      │──── dehydrated state ────►
                   │
browser hydrate    ● same data restored
                   │<------ 30s fresh ------->│
client subscribe   ● cached data emits
                   │
                   └── no immediate GET /api/todos

After the freshness window expires, ordinary Query/Cache rules apply again. M08 does not disable refetching; it prevents an unnecessary duplicate read during the initial server-to-browser handoff.

M08 is data hydration, not DOM hydration

The word "hydrate" here refers specifically to Query/Cache state.

The current DOM renderer's mount() still owns its container and calls replaceChildren(). When the browser client mounts, it may replace the static SSR snapshot with the live RxJS view.

That is deliberately outside the M08 claim.

M08 guarantees:

server query state ──► browser query state

It does not yet claim:

server DOM nodes ──► attach bindings in place

Keeping those two problems separate makes the architecture easier to reason about. Query-state continuity can be verified independently of a future DOM-hydration strategy.

M08 source map

src/queries/todos.ts
  createTodosQuery(fetcher)
  shared ['todos'] identity
  explicit staleTime freshness policy

src/server/route-context.ts
  request-scoped QueryClient + in-process fetch contract

src/routes/todos.tsx
  SSR query subscription
  request cancellation with takeUntil
  static TodoSnapshot construction

src/examples/todos.tsx
  shared TodoList
  static TodoSnapshot
  live TodoApp

src/query/ssr.ts
  QUERY_STATE_SCRIPT_ID
  hydrateQueryClientFromDocument()

src/render/html.ts
  generic application/json bootstrap scripts
  HTML-safe JSON serialization

src/server/app.tsx
  request-scoped QueryClient
  router context
  dehydrate() at the HTTP/HTML boundary

src/examples/todos-client.tsx
  restore cache before mount

Each file owns one piece of the handoff rather than hiding the complete process behind a new framework lifecycle.

M08 verification

scripts/verify.tsx now checks the continuity contract directly:

  • /todos SSR contains actual seeded Todo data,
  • /todos SSR no longer contains the Loading todos... placeholder,
  • the HTML contains the rxjs-query-state bootstrap element,
  • a server QueryClient executes a test query exactly once,
  • that QueryClient can be dehydrated,
  • a fresh browser QueryClient can restore the serialized state,
  • the browser query emits the server value after hydration,
  • the browser query function is not executed while the hydrated data remains fresh,
  • TypeScript and both browser bundles still build through the normal bun run check pipeline.

The most important executable assertion is:

server query executions   = 1
browser query executions  = 0
browser observed value    = server value

That is the concrete M08 definition of client data continuity.

What M08 establishes

M08 adds a reusable server/browser read path:

route declares query
      ↓
server subscribes
      ↓
request QueryClient remembers result
      ↓
SSR consumes resolved data
      ↓
QueryClient dehydrates into HTML
      ↓
browser QueryClient hydrates before subscription
      ↓
normal RxJS Query/Cache execution continues

The server and browser are no longer two unrelated executions that happen to request the same endpoint. They are two phases of one query lifecycle separated by an HTML transport boundary.

The broader principle is: SSR may resolve the first value, but Query/Cache remains the state machine that owns the read across the boundary.

M09 — Static Site Generation

M09 proves that request-time SSR and build-time static generation do not need separate application models.

The framework already had the complete page machine before M09:

route match
   ↓
route loaders
   ↓
Query/Cache where needed
   ↓
resolved PageData
   ↓
JSX ViewChild
   ↓
pure renderToString()
   ↓
HTML document

M09 keeps that machine and changes only when it is executed and what consumes the resulting HTML.

Request-time SSR:

HTTP request
    ↓
renderRouteDocument()
    ↓
HTML
    ↓
Hono Response

Build-time SSG:

build command
    ↓
static route pathname
    ↓
renderRouteDocument()
    ↓
HTML
    ↓
dist/static/.../index.html

This is the central M09 rule: SSG is not a new renderer. It is the existing route-document renderer executed during the build.

One route-document renderer now owns page resolution

Before M09, src/server/app.tsx contained both HTTP concerns and the reusable page-resolution work: it created the request QueryClient, called rxjs-router, rendered the resolved JSX, dehydrated query state, and then returned HTML through Hono.

M09 extracts the reusable part into:

src/render/page.ts

Its central operation is:

renderRouteDocument({
  routes,
  request,
  fetch,
})

Conceptually:

routes + Request + fetch boundary
            ↓
request/build-scoped QueryClient
            ↓
resolveRequest()
            ↓
route loaders
            ↓
resolved PageData
            ↓
renderToString(page.view)
            ↓
dehydrate(QueryClient)
            ↓
renderDocument()
            ↓
RouteDocumentResult

The result can be a page, redirect, not-found result, or error. The consumer decides what those values mean.

Hono now owns only the HTTP translation:

RouteDocumentResult.page      → context.html(...)
RouteDocumentResult.redirect  → context.redirect(...)
RouteDocumentResult.notFound  → HTTP 404
RouteDocumentResult.error     → HTTP 500

The static builder accepts only a successful page result and turns it into a file.

This extraction is important beyond M09: the framework now has an execution boundary that is independent of whether HTML is requested by a live HTTP client or generated ahead of time.

Static paths come from the generated router tree

M06 already made src/routes.generated.ts the generated application route tree. M09 deliberately does not add a second staticRoutes registry.

Instead, collectStaticPathnames() asks rxjs-router to normalize that same tree and reads each route node's fullPath.

For the current application:

generated route tree
      ↓
normalizeRoutes()
      ↓
/
/about
/counter
/todos

The current static set is therefore derived automatically:

/
/about
/counter
/todos

Adding another concrete file-discovered route later automatically makes it eligible for the same discovery process.

This preserves the M06 principle: there is one route tree, not one tree for runtime routing and another manifest for build tooling.

Parameterized routes are explicit future build inputs

A concrete route such as:

/about

can be generated immediately because its pathname is complete.

A route such as:

/posts/$slug

is different. The framework cannot know which slug values should exist at build time.

M09 therefore excludes route paths containing build-time parameters rather than guessing values or accidentally writing a literal $slug directory.

/posts/$slug
      ↓
requires explicit build-time params
      ↓
not generated by M09 automatic static discovery

This is intentional scope, not a limitation hidden by the generator. A later parameterized-SSG feature can supply a list such as:

/posts/rxjs
/posts/functional-programming
/posts/observables

and feed those concrete pathnames into the same renderStaticPage() operation without changing the renderer.

Static URL structure maps to directory index files

M09 uses deployment-friendly directory-index output:

/          → dist/static/index.html
/about     → dist/static/about/index.html
/counter   → dist/static/counter/index.html
/todos     → dist/static/todos/index.html

That mapping is owned by staticOutputPath().

The output structure means ordinary static hosts can serve clean URLs without requiring framework-specific URL rewriting just to remove .html suffixes.

Build-time pages reuse M08 Query/Cache prefetch

The most important proof route for M09 is /todos.

A simplistic SSG implementation could render only routes with synchronous data and leave query-backed pages to the browser. M09 instead reuses the M08 server execution path.

Build-time /todos is:

renderStaticPage('/todos')
      ↓
renderRouteDocument()
      ↓
Todos route loader
      ↓
request/build-scoped QueryClient
      ↓
queryClient.query$(createTodosQuery(fetch))
      ↓
GET /api/todos through supplied fetch boundary
      ↓
readonly Todo[]
      ↓
static TodoSnapshot
      ↓
dehydrate(QueryClient)
      ↓
HTML with prefetched Todos + query bootstrap
      ↓
dist/static/todos/index.html

The generated page therefore contains the same two outputs as M08 SSR:

visible resolved HTML
+
deferred browser Query/Cache state

The build process does not introduce a special Todo reader. scripts/generate-static.ts supplies an in-process fetch adapter to the same Hono API contract, so GET /api/todos remains the read boundary used by the query.

Build-time QueryClient state is isolated per generated page

renderRouteDocument() creates a fresh QueryClient for every page render.

That gives static generation the same isolation principle M08 established for HTTP requests:

/about build
    ↓
QueryClient A
    ↓
/about HTML

/todos build
    ↓
QueryClient B
    ↓
/todos HTML + dehydrated Todos state

Data from one generated page is not implicitly carried into another page's cache.

If the framework later chooses to introduce cross-page build caching, that will be an explicit optimization rather than an accidental consequence of one process-global QueryClient.

SSG does not weaken the pure HTML renderer

M03 remains unchanged.

renderToString() still receives only resolved values and still rejects Observable children.

The timing is:

build-time route/query execution
      ↓
resolved PageData
      ↓
plain/static ViewChild
      ↓
renderToString()

M09 therefore adds no subscription logic to the renderer. The static builder coordinates execution before the pure rendering boundary, exactly as SSR does.

SSG and SSR can be compared directly

Because both modes now call the same renderRouteDocument(), deterministic pages can be checked byte-for-byte.

For /about:

HTTP /about
    ↓
renderRouteDocument()
    ↓
SSR HTML

build /about
    ↓
renderRouteDocument()
    ↓
static HTML

M09 verification asserts that those two HTML strings are identical.

For query-backed pages such as /todos, dehydrated query timestamps naturally differ between separate executions, so verification checks the semantic invariants instead:

static page contains resolved Todo data       ✅
static page does not contain Loading todos... ✅
static page contains rxjs-query-state         ✅
bootstrap contains queryKey ['todos']         ✅

This distinguishes stable page semantics from incidental execution timestamps.

The build command is explicit

M09 adds:

bun run build:static

That command:

generate routes
      ↓
discover concrete static pathnames
      ↓
clear dist/static
      ↓
render each pathname
      ↓
create parent directory
      ↓
write index.html

The generator logs each mapping, for example:

/ -> dist/static/index.html
/about -> dist/static/about/index.html
/counter -> dist/static/counter/index.html
/todos -> dist/static/todos/index.html

dist/ remains build output and is therefore still excluded from Git.

M09 source map

src/render/page.ts
  renderRouteDocument()
  shared route/data/query/HTML execution
  page / redirect / notFound / error result

src/server/app.tsx
  Hono HTTP adapter around renderRouteDocument()

src/ssg/static.ts
  collectStaticPathnames()
  staticOutputPath()
  renderStaticPage()

scripts/generate-static.ts
  build entry point
  static path iteration
  output-directory creation
  HTML file writes

scripts/verify-static.ts
  physical artifact verification
  SSR/SSG /about equivalence
  static /todos Query/Cache assertions

scripts/verify.tsx
  static route discovery semantics
  parameterized-route exclusion
  in-memory build-time rendering assertions

package.json
  build:static
  verify:static
  M09 steps in the complete check pipeline

M09 verification

M09 is protected at two levels.

The normal executable verifier checks the framework behavior before files are written:

  • static pathnames are derived from the generated route tree,
  • the current set is exactly /, /about, /counter, and /todos,
  • root output maps to dist/static/index.html,
  • nested output maps to directory-index files,
  • parameterized $... routes are excluded without supplied build-time params,
  • build-time /about rendering equals request-time SSR byte-for-byte,
  • build-time /todos contains prefetched query data,
  • build-time /todos retains the M08 dehydrated Query/Cache bootstrap.

The static artifact verifier then runs after scripts/generate-static.ts and checks the actual files:

  • every discovered concrete route produced an HTML file,
  • dist/static/about/index.html equals live SSR output for /about,
  • dist/static/todos/index.html contains the seeded Todo data,
  • the static Todos page has no loading placeholder,
  • the static Todos page contains the rxjs-query-state element,
  • its dehydrated state contains the ['todos'] query identity.

The complete acceptance pipeline is now conceptually:

route generation
      ↓
strict TypeScript
      ↓
M01-M09 executable verification
      ↓
static site generation
      ↓
static artifact verification
      ↓
browser client bundles

What M09 establishes

M09 turns the page pipeline into a reusable execution engine rather than an HTTP-only feature.

                     ┌── request time ──► Hono Response
route/data/JSX/HTML ─┤
                     └── build time ────► static index.html

Everything above that final consumer remains the same:

routes
  ↓
loaders
  ↓
RxJS / Query/Cache execution
  ↓
resolved model
  ↓
JSX
  ↓
pure HTML rendering

That is the larger M09 result: SSR and SSG are execution-time policies around the same RxJS Fullstack page machine.

M10 — Runtime Adapters

M10 proves that runtime choice is outside the application machine.

By M09 the framework already had a runtime-neutral page and HTTP architecture. The remaining runtime-specific code was simply the process that hosted Hono. M10 makes that boundary explicit and verifies it across three hosting shapes:

                         ┌── Bun server
                         │
Web Request ─► app.fetch ├── Cloudflare-style Worker
                         │
                         └── Node.js bridge
                               ↓
Web Response

Everything inside app.fetch remains the same:

Request
  ↓
Hono routes / API
  ↓
rxjs-router
  ↓
RxJS Query/Cache + server actions
  ↓
SSR / route-document rendering
  ↓
Response

The central M10 rule is: runtime adapters host the application; they do not redefine the application.

The portable application boundary is Web Request → Response

M10 introduces src/runtime/fetch.ts:

export const fetchHandler = app.fetch;

That line is intentionally small. It represents the complete runtime-neutral contract:

(Request) → Response | Promise<Response>

Routes do not receive a Bun request type, a Node IncomingMessage, or a Cloudflare-specific event. Hono continues to expose Web Platform request/response values to the application.

This gives the framework one portable server boundary:

runtime-specific incoming request
            ↓
runtime host / bridge
            ↓
Web Request
            ↓
fetchHandler
            ↓
Web Response
            ↓
runtime-specific outgoing transport

Only the outermost arrows are runtime-specific.

Bun is now an explicit adapter rather than an implicit framework dependency

M04 introduced Bun as the first runtime. M10 moves its hosting policy into:

src/runtime/bun.ts

The adapter exposes ordinary Bun server options:

{
  port: 3000,
  fetch: fetchHandler,
}

The default port remains 3000, preserving the original development workflow.

The older src/server/bun.ts path remains as a compatibility re-export so M10 does not break the M04 entry point merely to reorganize runtime ownership.

Conceptually:

Bun
 ↓
createBunServerOptions()
 ↓
fetchHandler
 ↓
RxJS Fullstack application

Bun therefore remains a convenient development runtime and the build tool used by the project, but it is no longer the only demonstrated server host.

Fetch-native edge runtimes need almost no adapter

A runtime that already speaks the Web fetch contract does not require an HTTP translation layer.

M10 proves that with a Cloudflare Workers Module Worker shape:

export const cloudflareWorker = {
  fetch: fetchHandler,
};

The flow is:

Cloudflare-style fetch event
       ↓
worker.fetch(Request)
       ↓
fetchHandler(Request)
       ↓
Hono / RxJS Fullstack
       ↓
Response

The important point is not Cloudflare-specific functionality. It is the opposite: no Cloudflare-specific application functionality is needed for the core HTTP path.

The worker entry is built with a browser/edge target. That build is an architectural test: if Node-only modules accidentally leak into the fetch-native runtime dependency graph, the worker build should expose the portability violation.

Node.js uses a transport bridge, not a second application API

Node's traditional HTTP server API is not itself the Web fetch server contract, so M10 uses Hono's official Node adapter:

@hono/node-server

src/runtime/node-adapter.ts performs only this translation:

Node HTTP server
      ↓
@hono/node-server
      ↓
fetchHandler
      ↓
Web Response
      ↓
Node HTTP response

The adapter never calls route loaders directly and never knows about QueryClient, server actions, JSX, SSR, or SSG.

That separation is significant. A Node deployment does not become a parallel implementation of rxjs-fullstack; it is simply another host around the same application function.

The executable Node entry owns process policy

src/runtime/node.ts is the process-level Node entry point.

It owns runtime concerns that genuinely belong at the process boundary:

  • reading PORT,
  • validating the configured port,
  • starting the Node server,
  • logging the listening address,
  • handling SIGINT and SIGTERM,
  • closing the server during shutdown.

Those concerns do not leak into Hono routes or RxJS application pipelines.

The default remains:

port = 3000

and deployments can override it through:

PORT=8080 ...

Runtime choice does not change RxJS execution semantics

M10 introduces no new Observable type, scheduler, cancellation mechanism, query implementation, or action implementation.

For example, a browser action still flows as:

subscribe
   ↓
fromFetch
   ↓
HTTP request
   ↓
runtime host
   ↓
Hono server-action route
   ↓
executeServerAction$()
   ↓
Observable handler

Whether the server process is hosted by Bun or Node does not alter the exhaustMap, concatMap, unsubscription, query invalidation, or handler Observable semantics chosen by application code.

Likewise, SSR remains:

Request
  ↓
runtime adapter
  ↓
Hono
  ↓
renderRouteDocument()
  ↓
route loader / QueryClient
  ↓
renderToString()
  ↓
Response

The runtime adapter does not insert a new lifecycle into the middle of the RxJS machine.

M09 and M10 define two independent outer policies

M09 separated when a page is rendered:

request time → SSR
build time   → SSG

M10 separates what hosts request-time execution:

Bun        → fetch-native host
Cloudflare → fetch-native host
Node       → Node HTTP bridge → fetch handler

These axes are independent:

                         execution time
                    request            build
                       │                 │
                       ▼                 ▼
application      renderRouteDocument   renderRouteDocument
                       │                 │
                       ▼                 ▼
consumer          Web Response       static HTML file
                       │
             ┌─────────┼─────────┐
             ▼         ▼         ▼
            Bun      Node      Worker

This is useful because deployment concerns do not need to infect rendering or dataflow semantics.

Runtime bundles are part of the architecture check

M10 adds two explicit server build products:

dist/runtime/node.js

dist/runtime/cloudflare.js

The Node entry is built with a Node target. The Worker entry is built with a browser/edge target.

This gives the repository a practical dependency-boundary check:

Node-only dependency
       │
       ├── allowed in Node adapter graph
       │
       └── must not leak into worker graph

The application itself remains shared underneath both builds.

The Node proof runs under the actual node executable

M10 does not consider a successful TypeScript build sufficient proof of Node portability.

scripts/verify-runtimes.ts launches:

node dist/runtime/node.js

as a child process on an isolated test port.

The verifier then makes real HTTP requests to that process:

Node process
   ↓
GET /health
   ↓
200 {"ok":true}

Node process
   ↓
GET /about
   ↓
SSR HTML

It compares the Node /about HTML byte-for-byte with the output obtained through the runtime-neutral fetchHandler.

That assertion is important:

Node transport result == fetch-native application result

The Node adapter is therefore tested as a transparent transport bridge rather than trusted merely because it compiles.

Fetch-native adapters are compared directly

The same runtime verifier calls:

fetchHandler
bunRuntime.fetch
cloudflareRuntime.fetch

with Web Request values.

It checks that the adapters preserve the same application responses, including deterministic SSR output for /about.

The invariant is:

same Request
    ↓
different runtime host
    ↓
same application semantics
    ↓
equivalent Response

M10 source map

src/runtime/fetch.ts
  runtime-neutral app.fetch export
  canonical Web Request → Response boundary

src/runtime/bun.ts
  Bun port + fetch server options

src/runtime/cloudflare.ts
  fetch-native Module Worker shape

src/runtime/node-adapter.ts
  @hono/node-server bridge

src/runtime/node.ts
  executable Node process entry
  PORT + shutdown policy

src/server/bun.ts
  M04 compatibility re-export

scripts/verify-runtimes.ts
  fetch-native adapter comparison
  real Node child-process smoke test
  SSR response equivalence

package.json
  start:bun / start:node
  build:node / build:worker
  verify:runtimes
  runtime checks in the complete acceptance gate

M10 verification

M10 is verified at several levels.

TypeScript checks that the runtime boundaries and adapters agree structurally.

The build gate proves that:

  • the Node runtime dependency graph bundles for a Node target,
  • the Cloudflare-style runtime dependency graph bundles for a browser/edge target,
  • the existing browser clients still bundle independently.

The runtime verifier proves that:

  • the shared fetchHandler serves /health,
  • the Bun adapter exposes that same handler,
  • the Cloudflare-style adapter exposes that same handler,
  • fetch-native adapters produce equivalent application responses,
  • deterministic /about SSR is unchanged by the Worker wrapper,
  • dist/runtime/node.js starts under the actual node executable,
  • the Node process serves /health,
  • the Node process serves SSR routes,
  • Node /about HTML is byte-for-byte identical to the runtime-neutral fetch result,
  • the Node process can be terminated cleanly after verification.

The complete acceptance pipeline is now conceptually:

route generation
      ↓
strict TypeScript
      ↓
M01-M10 executable verification
      ↓
static site generation + verification
      ↓
Node runtime build
      ↓
edge/Worker runtime build
      ↓
actual runtime adapter verification
      ↓
browser client bundles

What M10 establishes

M10 turns the M04 runtime intention into an executable architectural invariant:

                           ┌── Bun
                           │
RxJS Fullstack app.fetch ──┼── fetch-native edge runtime
                           │
                           └── Node HTTP bridge

The framework does not ask application code which runtime it is running on in order to route, query, render, or execute actions.

Instead:

runtime chooses hosting policy
application chooses dataflow policy

That is the larger M10 result: Bun, Node.js, and fetch-native edge runtimes are hosts around the same RxJS Fullstack machine, not separate versions of the framework.

M11 — Database Integration

M11 replaces the process-local Todo array with an explicit persistence boundary while preserving the RxJS Fullstack machine built in M01–M10.

The database is deliberately treated as another effect dependency. It does not become a new state-management system, router, action mechanism, or rendering lifecycle.

Before M11, the server-side Todo source of truth was a module-local array:

GET /api/todos ─────────────► in-process Todo[]

server action ──────────────► addTodo()
                                  ↓
                            in-process Todo[]

M11 changes the persistence boundary to:

                           TodoRepository
                          /              \
                         /                \
                        ▼                  ▼
              memory repository       PGlite/Postgres
              tests / portable        Bun / Node
              compositions            persistent host

The application sees only the repository contract. The composition root decides which implementation supplies the effect.

The central M11 rule is: persistence changes where Todo data is stored; it does not change how values move through RxJS Fullstack.

The repository is a typed effect port

The application-facing database contract lives in:

src/database/todos-repository.ts

Its essential shape is:

interface TodoRepository {
  list$(options?): Observable<readonly Todo[]>;
  create$(input, options?): Observable<Todo>;
  close(): Promise<void>;
}

This contract says what packages move through the persistence boundary:

list$    : ()              → Observable<readonly Todo[]>
create$  : CreateTodoInput → Observable<Todo>

It deliberately does not expose:

  • PGlite objects,
  • SQL result objects,
  • Hono contexts,
  • QueryClient,
  • browser state,
  • server-action references,
  • runtime-specific APIs.

The database adapter knows persistence. The rest of the framework continues to know only domain values and Observables.

Database reads and writes remain cold

Repository operations are descriptions until subscribed.

For a write:

const save$ = repository.create$(input)
        │
        │ no subscription
        ▼
      no SQL

subscribe
   ↓
INSERT starts
   ↓
Todo emitted
   ↓
complete

Both the memory repository and the PGlite repository implement read/write operations with defer(...).

This means constructing a database Observable does not eagerly perform the database effect. The same RxJS execution rule that applies to HTTP actions and other effects now applies to persistence.

Repository initialization is intentionally different from repository operations

M11 separates two lifetimes that should not be confused.

Repository initialization belongs to the composition root:

process starts
    ↓
open database
    ↓
apply migrations
    ↓
seed if empty
    ↓
construct application
    ↓
start accepting requests

Individual reads and writes remain cold:

request arrives
    ↓
repository.list$() or repository.create$()
    ↓
Observable description
    ↓
subscription at the HTTP/action boundary
    ↓
SQL executes

Opening the database and making the schema ready before the server accepts traffic is intentional. Laziness applies to application database effects, not to pretending that a server can use an unopened database.

PGlite is the reference Postgres adapter

M11 uses PGlite as the reference embedded Postgres implementation for Bun and Node.js.

The adapter lives in:

src/database/pglite-todos-repository.ts

The important architectural choice is not that every rxjs-fullstack application must use PGlite. It is that a concrete Postgres-compatible database can satisfy the same TodoRepository port without leaking database-specific APIs upward.

The current persistent composition is:

Bun / Node process
      ↓
createPgliteTodoRepository()
      ↓
TodoRepository
      ↓
createApp({ todosRepository })
      ↓
Hono / RxJS Fullstack

A later PostgreSQL server, SQLite adapter, remote database service, or application-specific persistence layer can implement the same role without changing the browser query/action machine.

SQL stays visible

M11 deliberately does not add an ORM.

The Todo read is conceptually:

SELECT id, title, done
FROM todos
ORDER BY id

The write is a parameterized statement:

INSERT INTO todos (title, done)
VALUES ($1, FALSE)
RETURNING id, title, done

The domain value is passed separately from the SQL text:

CreateTodoInput.title
       ↓
parameter $1
       ↓
Postgres insert
       ↓
Todo row
       ↓
Todo

Keeping SQL explicit matches the rest of the project: mechanisms remain visible rather than being renamed or hidden behind domain-sounding wrappers.

Schema changes are versioned migrations

The database adapter does not assume that the schema already exists.

M11 adds a migration ledger:

rxjs_fullstack_migrations

The first application migration is:

version 1 — create_todos

which creates:

todos
├── id    SERIAL PRIMARY KEY
├── title TEXT NOT NULL
└── done  BOOLEAN NOT NULL DEFAULT FALSE

Initialization proceeds as:

open PGlite
    ↓
ensure migration ledger exists
    ↓
read applied versions
    ↓
for each unapplied migration
    ↓
transaction
   ├── apply SQL
   └── record version/name

The schema history is therefore explicit and repeatable instead of being inferred from application objects at runtime.

Seed data is idempotent

The two canonical Todo examples remain useful as the initial project dataset.

M11 moves them into a shared seed definition and inserts them only when the Todo table is empty.

fresh database
    ↓
COUNT(*) = 0
    ↓
insert canonical seed rows

reopened database
    ↓
COUNT(*) > 0
    ↓
do not seed again

This matters because persistence must survive restart without duplicating the example data every time the application boots.

The old array store disappears from the active architecture

M07's historical chapter documents the original src/server/todos-store.ts because that was the implementation at that milestone.

M11 removes that file from the current source tree.

The transition is:

M07–M10
server/api.ts
    ↓
todos-store.ts
    ↓
module-local array

M11
server/api.ts
    ↓
TodoRepository
    ↓
selected repository adapter

The historical README remains intact, but the current application no longer imports a persistence implementation directly from the server API.

createApi() receives persistence instead of importing it

The API boundary is now constructed with:

createApi({ todosRepository })

The read path becomes:

GET /api/todos
      ↓
todosRepository.list$({ signal })
      ↓
firstValueFrom(...)
      ↓
readonly Todo[]
      ↓
JSON Response

The write path becomes:

POST /api/actions/todos.create
      ↓
server-action validation
      ↓
todosRepository.create$(inpu