From 5d932452ede482390482913b55f7d73d9e45017d Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 17 Feb 2026 14:22:29 +0000 Subject: [PATCH 1/2] draft design docs --- SSR_ALT_DESIGN.md | 416 ++++++++++++++++++++++ SSR_ALT_NEXTJS_EXAMPLE.md | 221 ++++++++++++ SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md | 247 +++++++++++++ SSR_ALT_TANSTACK_START_EXAMPLE.md | 265 ++++++++++++++ SSR_DESIGN.md | 485 ++++++++++++++++++++++++++ SSR_NEXTJS_EXAMPLE.md | 249 +++++++++++++ SSR_REACT_ROUTER_REMIX_EXAMPLE.md | 193 ++++++++++ SSR_TANSTACK_START_EXAMPLE.md | 175 ++++++++++ 8 files changed, 2251 insertions(+) create mode 100644 SSR_ALT_DESIGN.md create mode 100644 SSR_ALT_NEXTJS_EXAMPLE.md create mode 100644 SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md create mode 100644 SSR_ALT_TANSTACK_START_EXAMPLE.md create mode 100644 SSR_DESIGN.md create mode 100644 SSR_NEXTJS_EXAMPLE.md create mode 100644 SSR_REACT_ROUTER_REMIX_EXAMPLE.md create mode 100644 SSR_TANSTACK_START_EXAMPLE.md diff --git a/SSR_ALT_DESIGN.md b/SSR_ALT_DESIGN.md new file mode 100644 index 000000000..2b7eaea29 --- /dev/null +++ b/SSR_ALT_DESIGN.md @@ -0,0 +1,416 @@ +# SSR Alt Design: Scope-Aware Getters with Explicit Transfer + +Status: Draft proposal +Target: `@tanstack/db` core + framework adapters +Replaces: prior high-ceremony SSR draft design + +## Summary + +This alt design keeps five core primitives: + +1. `createDbScope` +2. `ProvideDbScope` +3. `useDbScope` +4. `defineCollection` +5. `defineLiveQuery` + +It explicitly separates two concerns: + +1. Lifecycle binding: pass `scope` to a getter. +2. Transfer intent: call `scope.include(collection)`. + +## Review-Driven Updates + +This revision addresses the recent review concerns: + +1. Silent missing-scope bugs: add required-scope getter mode and strict hook behavior. +2. Memoization ambiguity: define stable cache key and miss/hit behavior. +3. QueryClient lifecycle ambiguity: factory execution contract is explicit. +4. Scope-threading risks: add strict runtime guard and optional scope hook. +5. Live query prune semantics: define hydration timing guarantee. +6. Sync resume gap: define v1 metadata shape and defer advanced policy knobs. +7. Undefined dehydrated state: define `DehydratedDbStateV1`. +8. RSC cleanup timing: define safe patterns and example guidance. + +## API Surface + +```ts +interface DbScope { + include(collection: Collection): void + serialize(): DehydratedDbStateV1 + cleanup(): Promise +} + +declare function createDbScope(): DbScope + +declare function useDbScope(): DbScope +declare function useOptionalDbScope(): DbScope | undefined + +declare function ProvideDbScope(props: { + scope?: DbScope + state?: DehydratedDbStateV1 + children: React.ReactNode +}): JSX.Element +``` + +Hook semantics: + +1. `useDbScope()` throws if no provider is active. +2. `useOptionalDbScope()` is for mixed SSR/CSR trees where provider presence is conditional. + +## Getter APIs + +### `defineCollection` + +```ts +interface DefineGetterOptions { + scope?: 'optional' | 'required' +} + +type DefineGetterCallArgs< + TParams extends object, + TOptions extends DefineGetterOptions | undefined, +> = TOptions extends { scope: 'required' } + ? [params: TParams, scope: DbScope] + : [params: TParams, scope?: DbScope] + +declare function defineCollection< + TOptions extends CollectionConfig & { id: string }, + TGetterOptions extends DefineGetterOptions | undefined = undefined, +>( + getOptions: (scope?: DbScope) => TOptions, + options?: TGetterOptions, +): TGetterOptions extends { scope: 'required' } + ? (scope: DbScope) => InferCollectionFromOptions + : (scope?: DbScope) => InferCollectionFromOptions + +declare function defineCollection< + TParams extends object, + TOptions extends CollectionConfig & { id: string }, + TGetterOptions extends DefineGetterOptions | undefined = undefined, +>( + getOptions: (params: TParams, scope?: DbScope) => TOptions, + options?: TGetterOptions, +): ( + ...args: DefineGetterCallArgs +) => InferCollectionFromOptions +``` + +### `defineLiveQuery` + +```ts +declare function defineLiveQuery< + TOptions extends LiveQueryCollectionConfig & { id: string }, + TGetterOptions extends DefineGetterOptions | undefined = undefined, +>( + getOptions: (scope?: DbScope) => TOptions, + options?: TGetterOptions, +): TGetterOptions extends { scope: 'required' } + ? (scope: DbScope) => LiveQueryCollectionFromOptions + : (scope?: DbScope) => LiveQueryCollectionFromOptions + +declare function defineLiveQuery< + TParams extends object, + TOptions extends LiveQueryCollectionConfig & { id: string }, + TGetterOptions extends DefineGetterOptions | undefined = undefined, +>( + getOptions: (params: TParams, scope?: DbScope) => TOptions, + options?: TGetterOptions, +): ( + ...args: DefineGetterCallArgs +) => LiveQueryCollectionFromOptions +``` + +Behavior: + +1. Parameterless getters avoid the `({}, scope)` pattern. +2. `scope: 'required'` causes runtime throw when scope is missing. +3. Type signature for required-scope getters requires a `scope` arg. +4. In dev mode, optional-scope getters called without scope while an active scope is detectable should emit a warning. + +## Memoization Contract + +Memoization is per getter function identity. + +Key: + +1. `scopeSlot`: scope instance identity, or global slot when no scope. +2. `paramsKey`: deterministic structural hash of params (see constraints below). +3. `getterId`: identity of the returned getter function. + +Rules: + +1. Cache key is `getterId + scopeSlot + paramsKey`. +2. `getOptions(...)` executes only on cache miss. +3. Cache hit returns the exact same collection/live-query instance. +4. In dev mode, if the same memo key resolves to a different `options.id`, throw. + +Consequence: + +1. Getters are safe to call repeatedly in render. +2. Factories that allocate resources (for example `QueryClient`) do not run on hits. + +### Param Hashing Constraints + +Params must be plain objects with deterministically hashable values. The structural hash is computed as follows: + +1. Object keys are sorted lexicographically before hashing to ensure key-order independence. +2. Supported value types: `string`, `number`, `boolean`, `null`, `Date`, `BigInt`, plain objects, and arrays of supported types. +3. `Date` values are hashed by their numeric timestamp (`Date.getTime()`). +4. `BigInt` values are hashed by their string representation with a type prefix to avoid collision with numeric strings. +5. `undefined` values are treated as absent keys and excluded from the hash. +6. `Map`, `Set`, `RegExp`, class instances, functions, and `Symbol` are not supported as param values. +7. Cyclic references are not supported. +8. In dev mode, encountering an unsupported value type in params should throw with a descriptive error naming the offending key and type. +9. In production, unsupported values fall back to `String(value)`, which may produce collisions. This is intentional to avoid runtime cost; the dev-mode check is the guardrail. + +This ensures all environments and adapters produce identical cache keys for the same logical params. + +Each value type is hashed with a unique type prefix (for example `s:` for strings, `n:` for numbers, `d:` for dates, `bi:` for bigints). This means new types can be added to the supported set in future versions without changing hashes produced by existing types. The supported type list is not user-extensible; changes require a library update. + +> **TODO**: Arrays are listed as supported above but the hashing details need more thought. Array params are likely common (for example a list of ids or tag filters). Open questions: should array order be significant for the hash? Should sparse arrays be normalized? Should nested arrays be supported or restricted to a single level? Resolve before implementation. + +## Scope vs Transfer Semantics + +1. Passing `scope` binds instance lifecycle to request scope. +2. Passing `scope` does not imply transfer. +3. `scope.include(collection)` opts a collection into snapshot transfer. +4. `useLiveQuery(...)` tracks live-query usage for `ssr.serializes`, but does not call `include(...)` on source collections. + +## Scope Placement Strategies + +Two placement strategies are supported. The choice depends on framework capabilities. + +### Strategy 1: Single Root Scope (Preferred) + +One scope per request, shared across all loaders, serialized once by the root component during SSR render. + +Flow: + +1. Create `dbScope` once per request (router creation, middleware, or request context). +2. Pass `dbScope` to all loaders via framework context. +3. Each loader uses `dbScope` to create collections, preload, and call `include(...)`. +4. After all loaders complete, the server begins rendering the component tree. +5. Root component calls `dbScope.serialize()` and renders ``. +6. Cleanup runs after render via middleware `finally` or router teardown. + +Benefits: + +1. Collection instances are shared across loaders via memoization (same scope, same params = same instance). +2. No duplicate data loading for collections used by multiple routes. +3. Single `ProvideDbScope` at the root; no nesting needed. +4. Clean mental model: one scope = one request = one serialized payload. + +Requirement: + +1. The framework must allow passing a live scope object to the root component during SSR render. +2. All matched loaders must complete before the root component renders (true for TanStack Start and React Router SSR). + +Use when: TanStack Start (via router context). + +### Strategy 2: Per-Loader Scope with Merge + +Each loader creates its own scope, serializes independently, and its route component provides a `ProvideDbScope`. Nested providers merge state on the client. + +Flow: + +1. Each loader creates its own `dbScope`. +2. Loader preloads, calls `include(...)`, serializes, and cleans up in `finally`. +3. Each route component renders ``. +4. Nested `ProvideDbScope` components merge their state into the parent scope on the client. + +Benefits: + +1. Each loader is self-contained with clear ownership of create, serialize, cleanup. +2. No coordination required between parallel loaders. +3. Works in frameworks where scope objects cannot be passed from loaders to components (the only serializable output is `dbState`). + +Tradeoff: + +1. Collections used by multiple loaders are separate instances (different scopes), so data may be fetched more than once on the server. +2. Multiple `ProvideDbScope` providers in the component tree. + +Use when: React Router / Remix (parallel loaders, serializable boundary), Next.js (per-page or per-server-component scope). + +## ProvideDbScope Nesting and Merge + +When `ProvideDbScope` is nested inside another `ProvideDbScope`, the inner provider's state merges into the scope visible to its descendants. + +```tsx + + {/* useDbScope() here sees parentDbState */} + + {/* useDbScope() here sees parentDbState + childDbState merged */} + + + +``` + +Merge rules: + +1. Collection snapshots merge by `id`. On conflict, the entry with the later `generatedAt` timestamp wins. If timestamps are equal, the child entry wins. +2. Live query payloads merge by `id`. On conflict, the entry with the later `updatedAt` timestamp wins. If timestamps are equal, the child entry wins. +3. The merged scope is a new client-side scope. Getter memoization uses the nearest scope identity. +4. Merge happens at provider mount time. It is not reactive to parent state changes after mount. + +Freshness rationale: + +1. During initial SSR, all loaders for a request run at roughly the same time, so timestamps are nearly identical and tree position (child wins) is the effective tiebreaker. +2. During client-side route transitions, a child route loader may run later than the parent's cached data. Timestamp comparison ensures the fresher payload is not overwritten by stale cached parent data. +3. `generatedAt` on `DehydratedDbStateV1` and `updatedAt` on individual live query entries provide the freshness signal. Both are set at `serialize()` time. + +When nesting is not needed: + +1. Single root scope strategy uses one `ProvideDbScope` at the root. No merge required. +2. Next.js App Router typically has one `ProvideDbScope` per page server component. No nesting. + +When nesting is expected: + +1. React Router / Remix with per-loader scopes and nested routes. +2. Any layout where a parent route and child route each provide their own `dbState`. + +## Dehydrated State Shape + +```ts +interface DehydratedDbStateV1 { + version: 1 + generatedAt: number + collections: Array<{ + id: string + rows: ReadonlyArray + meta?: unknown + }> + liveQueries: Array<{ + id: string + data: unknown + updatedAt: number + }> +} +``` + +Notes: + +1. Only JSON-serializable data is allowed. +2. `meta` is optional sync metadata for resume-capable collections. + +## Live Query Serialization and Pruning + +`ssr: { serializes: true }` marks live query result transfer candidates. + +At `scope.serialize()`: + +1. Build `includedCollectionIds` from `scope.include(...)`. +2. Gather used or preloaded live queries with `ssr.serializes: true`. +3. Compute each candidate's dependency collection ids. +4. Skip candidate if all dependencies are covered by included collection snapshots. +5. Otherwise include the live query payload. + +Hydration timing guarantee: + +1. `ProvideDbScope` applies transferred collection snapshots and live query payloads before first descendant render. +2. Derived live queries (for example `.findOne()`) are available from hydrated sources on initial client render. + +## Sync Metadata and Resume + +V1 behavior: + +1. Collection snapshots may include optional `meta`. +2. If collection implementation supports resume from `meta`, it may resume. +3. If metadata is missing or incompatible, collection should restart from truncate/reload. + +Out of scope for this doc: + +1. Detailed `onIncompatibleSyncState` policy matrix. +2. Multi-backend sync adapters and migration tooling. + +Those are a follow-up design phase. + +## Scope Lifecycle and Cleanup + +Rules: + +1. A scope should be serialized once for a given response payload boundary. +2. `cleanup()` should run only after the framework is done using scope-owned resources. +3. In RSC render flows, do not rely on `finally` around `return ` when passing live `scope` object unless framework guarantees post-render finalization hooks. + +Cleanup by strategy: + +1. Single root scope: cleanup runs in middleware `finally` or router teardown, after the full response is sent. +2. Per-loader scope: cleanup runs in each loader's own `finally` block, after `serialize()`. + +Safe RSC default: + +1. Compute `const dbState = scope.serialize()` before returning JSX. +2. Pass `state` to `ProvideDbScope`. +3. Cleanup in `finally`. + +## Framework Integration Guidance + +### TanStack Start (single root scope) + +1. Create `dbScope` in `createRouter()` alongside `QueryClient`. +2. Pass `dbScope` through router context so all loaders access it via `context.dbScope`. +3. Each loader uses the shared scope: creates collections, preloads, calls `include(...)`. +4. Loaders return application data only (no `dbState`). +5. Root component calls `dbScope.serialize()` during render and provides ``. +6. Middleware `finally` handles cleanup after the response is complete. + +This is the preferred pattern because TanStack Start creates a fresh router per request and makes router context available to both loaders and components. + +### React Router and Remix (per-loader scope with merge) + +1. Each loader creates its own `dbScope`, preloads, serializes, and cleans up. +2. Each route component wraps its subtree in ``. +3. Nested providers merge state so child routes contribute additional data. + +Per-loader scope is recommended because React Router loaders run in parallel and each must return serializable data independently. A shared scope would require a coordination mechanism that the framework does not provide. + +### Next.js App Router RSC (per-page scope) + +1. Each page server component creates its own `dbScope`. +2. Serialize before returning JSX for safe cleanup timing. +3. One `ProvideDbScope` per page. + +A single root scope is not viable because RSC layouts are cached across navigations and are not re-executed per request. There is no per-request root entry point that can create and share a scope. + +### Next.js Pages Router (per-page scope) + +1. `getServerSideProps` creates `dbScope`, preloads, serializes, and cleans up. +2. Page component receives `dbState` as a prop and renders ``. + +Single entry point per page, so scope placement is straightforward. + +## Backwards Compatibility + +1. Existing non-SSR global collection usage remains valid. +2. SSR support is additive through `createDbScope` + `ProvideDbScope`. +3. Prior experimental APIs from draft PR are superseded by this surface. + +## Non-Goals (V1) + +1. Streaming-partial SSR state commits across multiple flush checkpoints. +2. Advanced sync-state compatibility policies. +3. Automatic ambient-scope inference on server via AsyncLocalStorage. + +## Testing Plan + +1. Getter memoization hit/miss behavior and deterministic param hashing. +2. Scope isolation across concurrent requests. +3. Required-scope getter runtime/type behavior. +4. Include-only transfer semantics for collections. +5. Live-query candidate pruning correctness and order independence. +6. Hydration timing guarantee for derived live queries. +7. Cleanup behavior across framework adapter integration tests. +8. Nested `ProvideDbScope` merge: child overrides parent by id. +9. Nested `ProvideDbScope` merge: `useDbScope()` returns merged scope. +10. Single root scope: shared memoization across loaders using same scope. +11. Single root scope: serialize captures all loaders' contributions. +12. Param hash canonicalization: key order independence (`{ a, b }` equals `{ b, a }`). +13. Param hash canonicalization: `undefined` values excluded from hash. +14. Param hash: `Date` hashed by timestamp, `BigInt` hashed with type prefix. +15. Param hash: dev-mode throw on unsupported types (`Map`, `Set`, functions, `Symbol`, cyclic references). +16. Param hash: nested objects and arrays produce stable keys. +17. Param hash: type prefixes prevent cross-type collisions (for example `"1"` vs `1` vs `1n`). +18. Merge freshness: entry with later timestamp wins over tree-position default. +19. Merge freshness: equal timestamps fall back to child-wins. +20. Merge freshness: client-side route transition with stale parent cache does not overwrite fresher child data. diff --git a/SSR_ALT_NEXTJS_EXAMPLE.md b/SSR_ALT_NEXTJS_EXAMPLE.md new file mode 100644 index 000000000..74ad89466 --- /dev/null +++ b/SSR_ALT_NEXTJS_EXAMPLE.md @@ -0,0 +1,221 @@ +# Next.js Example (Alt API): Per-Page Scope + +This example uses per-page scope placement. + +In Next.js App Router, layouts are cached across navigations and are not re-executed per +request, so there is no per-request root entry point that can create a shared scope. +Each page server component creates its own scope, serializes before returning JSX, +and provides state to a single `ProvideDbScope` per page. + +In Next.js Pages Router, `getServerSideProps` is the natural scope boundary: one scope +per page load, serialize and cleanup within the same function. + +Additional conventions: + +1. Parameterless getters avoid `{}` placeholders. +2. Request-sensitive getters use `scope: 'required'`. +3. Both routers transfer via `state` payload. +4. Cleanup runs after `serialize()` in the same function. + +## 1) Shared Getters + +```ts +// app/db/getters.ts +import { QueryClient } from '@tanstack/query-core' +import type { DbScope } from '@tanstack/db/ssr' +import { defineCollection, defineLiveQuery } from '@tanstack/db/ssr' +import { liveQueryCollectionOptions } from '@tanstack/db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' + +const globalCatalogQueryClient = new QueryClient() +const scopedQueryClients = new WeakMap() + +function getScopedQueryClient(scope: DbScope): QueryClient { + let queryClient = scopedQueryClients.get(scope) + if (!queryClient) { + queryClient = new QueryClient() + scopedQueryClients.set(scope, queryClient) + } + return queryClient +} + +// Process/global collection. Scope is optional. +export const getCatalogCollection = defineCollection((scope) => + queryCollectionOptions({ + id: `catalog`, + queryKey: [`catalog`], + queryClient: scope ? getScopedQueryClient(scope) : globalCatalogQueryClient, + queryFn: fetchCatalogRows, + getKey: (row) => row.id, + }), +) + +// Request-sensitive collection. Scope required. +export const getAccountCollection = defineCollection( + ({ userId }: { userId: string }, scope: DbScope) => + queryCollectionOptions({ + id: `account:${userId}`, + queryKey: [`account`, userId], + queryClient: getScopedQueryClient(scope), + queryFn: () => fetchAccountRows(userId), + getKey: (row) => row.id, + }), + { scope: 'required' }, +) + +export const getCatalogGridLiveQuery = defineLiveQuery((scope) => + liveQueryCollectionOptions({ + id: `catalog-grid`, + query: (q) => q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), + ssr: { serializes: true }, + }), +) + +export const getAccountSummaryLiveQuery = defineLiveQuery( + ({ userId }: { userId: string }, scope: DbScope) => + liveQueryCollectionOptions({ + id: `account-summary:${userId}`, + query: (q) => q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), + ssr: { serializes: true }, + }), + { scope: 'required' }, +) +``` + +## 2) App Router Server Component (RSC-Safe) + +```tsx +// app/store/page.tsx +import { cookies } from 'next/headers' +import { createDbScope } from '@tanstack/db/ssr' +import { ProvideDbScope } from '@tanstack/react-db/ssr' +import { + getAccountCollection, + getAccountSummaryLiveQuery, + getCatalogGridLiveQuery, +} from '@/db/getters' +import { StorePageClient } from './StorePageClient' + +export default async function StorePage() { + const cookieStore = await cookies() + const userId = getUserIdFromCookies(cookieStore) + const dbScope = createDbScope() + + try { + const accountCollection = getAccountCollection({ userId }, dbScope) + const catalogGrid = getCatalogGridLiveQuery(dbScope) + const accountSummary = getAccountSummaryLiveQuery({ userId }, dbScope) + + await Promise.all([catalogGrid.preload(), accountSummary.preload()]) + + dbScope.include(accountCollection) + const dbState = dbScope.serialize() + + // In RSC flows, passing `state` avoids relying on post-render scope lifetime. + return ( + + + + ) + } finally { + await dbScope.cleanup() + } +} +``` + +## 3) Client Component + +```tsx +'use client' + +import { useLiveQuery } from '@tanstack/react-db' +import { useDbScope } from '@tanstack/react-db/ssr' +import { + getAccountSummaryLiveQuery, + getCatalogGridLiveQuery, +} from '@/db/getters' + +export function StorePageClient({ userId }: { userId: string }) { + const scope = useDbScope() + const catalogGrid = getCatalogGridLiveQuery(scope) + const accountSummary = getAccountSummaryLiveQuery({ userId }, scope) + + const { data: catalog } = useLiveQuery(catalogGrid) + const { data: account } = useLiveQuery(accountSummary) + + return ( +
+

Store

+

{account?.email}

+
    + {catalog.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ) +} +``` + +## 4) Pages Router Loader-Style Example + +```tsx +// pages/store.tsx +import type { GetServerSideProps } from 'next' +import { createDbScope } from '@tanstack/db/ssr' +import { ProvideDbScope } from '@tanstack/react-db/ssr' +import { + getAccountCollection, + getAccountSummaryLiveQuery, +} from '@/db/getters' +import { StorePageClient } from '@/app/store/StorePageClient' + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const dbScope = createDbScope() + const userId = getUserIdFromCookies(ctx.req.cookies) + + try { + const accountCollection = getAccountCollection({ userId }, dbScope) + const accountSummary = getAccountSummaryLiveQuery({ userId }, dbScope) + + await accountSummary.preload() + dbScope.include(accountCollection) + + return { + props: { + userId, + dbState: dbScope.serialize(), + }, + } + } finally { + await dbScope.cleanup() + } +} + +export default function StorePage(props: { userId: string; dbState: unknown }) { + return ( + + + + ) +} +``` + +## 5) Process-Scoped Pattern + +```ts +// global/process memoization +const catalog = getCatalogCollection() +``` + +```ts +// request lifecycle binding +const account = getAccountCollection({ userId }, dbScope) +``` + +## Why Per-Page Scope for Next.js + +1. App Router layouts (`layout.tsx`) are React Server Components that can be cached and reused across navigations. They do not re-execute per request, so they cannot host a per-request scope. +2. Page server components (`page.tsx`) do execute per request, making them the correct scope boundary. +3. `getServerSideProps` in Pages Router is inherently per-page per-request. +4. Unlike TanStack Start, Next.js does not have a per-request router creation step or middleware context that flows to both data loading and rendering. The natural boundary is the page. diff --git a/SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md b/SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md new file mode 100644 index 000000000..cd407f965 --- /dev/null +++ b/SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md @@ -0,0 +1,247 @@ +# React Router / Remix Example (Alt API): Per-Loader Scope with Merge + +This example uses per-loader scopes with nested `ProvideDbScope` merge. + +React Router loaders run in parallel and each must return serializable data independently. +A shared scope would require a coordination mechanism the framework does not provide, +so each loader owns its own scope lifecycle: create, preload, serialize, cleanup. + +Nested `ProvideDbScope` providers merge their state on the client so child routes +contribute additional data to the scope visible to their descendants. + +## 1) Shared Getters + +```ts +// app/db/getters.ts +import type { DbScope } from '@tanstack/db/ssr' +import { defineCollection, defineLiveQuery } from '@tanstack/db/ssr' +import { eq, liveQueryCollectionOptions } from '@tanstack/db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' + +export const getCatalogCollection = defineCollection((scope?: DbScope) => + queryCollectionOptions({ + id: `catalog`, + queryKey: [`catalog`], + queryFn: fetchCatalogRows, + getKey: (row) => row.id, + }), +) + +export const getAccountCollection = defineCollection( + ({ userId }: { userId: string }, scope: DbScope) => + queryCollectionOptions({ + id: `account:${userId}`, + queryKey: [`account`, userId], + queryFn: () => fetchAccountRows(userId), + getKey: (row) => row.id, + }), + { scope: 'required' }, +) + +export const getCatalogGridLiveQuery = defineLiveQuery((scope?: DbScope) => + liveQueryCollectionOptions({ + id: `catalog-grid`, + query: (q) => q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), + ssr: { serializes: true }, + }), +) + +export const getAccountSummaryLiveQuery = defineLiveQuery( + ({ userId }: { userId: string }, scope: DbScope) => + liveQueryCollectionOptions({ + id: `account-summary:${userId}`, + query: (q) => q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), + ssr: { serializes: true }, + }), + { scope: 'required' }, +) + +export const getCatalogCategoryLiveQuery = defineLiveQuery( + ({ category }: { category: string }, scope?: DbScope) => + liveQueryCollectionOptions({ + id: `catalog-category:${category}`, + query: (q) => + q + .from({ c: getCatalogCollection(scope) }) + .where(({ c }) => eq(c.category, category)), + ssr: { serializes: true }, + }), +) +``` + +## 2) Parent Route Loader + +```ts +// app/routes/store.tsx +import { json } from 'react-router' +import { createDbScope } from '@tanstack/db/ssr' +import { + getAccountCollection, + getAccountSummaryLiveQuery, + getCatalogGridLiveQuery, +} from '@/db/getters' + +export async function loader({ request }: LoaderFunctionArgs) { + const dbScope = createDbScope() + const userId = getUserIdFromRequest(request) + + try { + const accountCollection = getAccountCollection({ userId }, dbScope) + const catalogGrid = getCatalogGridLiveQuery(dbScope) + const accountSummary = getAccountSummaryLiveQuery({ userId }, dbScope) + + await Promise.all([catalogGrid.preload(), accountSummary.preload()]) + + dbScope.include(accountCollection) + + return json({ + userId, + dbState: dbScope.serialize(), + }) + } finally { + await dbScope.cleanup() + } +} +``` + +## 3) Parent Route Component + Client View + +```tsx +// app/routes/store.tsx (continued) +import { ProvideDbScope } from '@tanstack/react-db/ssr' +import { useLoaderData, Outlet } from 'react-router' +import { StoreView } from '@/components/store-view' + +export default function StoreRoute() { + const { dbState, userId } = useLoaderData() + + return ( + + + {/* Child route renders inside Outlet. Its ProvideDbScope + merges with this one so descendants see combined state. */} + + + ) +} +``` + +```tsx +// app/components/store-view.tsx +import { useLiveQuery } from '@tanstack/react-db' +import { useDbScope } from '@tanstack/react-db/ssr' +import { + getAccountSummaryLiveQuery, + getCatalogGridLiveQuery, +} from '@/db/getters' + +export function StoreView({ userId }: { userId: string }) { + const scope = useDbScope() + const catalogGrid = getCatalogGridLiveQuery(scope) + const accountSummary = getAccountSummaryLiveQuery({ userId }, scope) + + const { data: catalog } = useLiveQuery(catalogGrid) + const { data: account } = useLiveQuery(accountSummary) + + return ( +
+

Store

+

{account?.email}

+
    + {catalog.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ) +} +``` + +## 4) Child Route with Nested ProvideDbScope + +```tsx +// app/routes/store.catalog.$category.tsx +import { json, useLoaderData } from 'react-router' +import { createDbScope } from '@tanstack/db/ssr' +import { ProvideDbScope } from '@tanstack/react-db/ssr' +import { useDbScope } from '@tanstack/react-db/ssr' +import { useLiveQuery } from '@tanstack/react-db' +import { getCatalogCategoryLiveQuery } from '@/db/getters' + +export async function loader({ params }: LoaderFunctionArgs) { + const dbScope = createDbScope() + const category = params.category ?? `all` + + try { + const categoryQuery = getCatalogCategoryLiveQuery({ category }, dbScope) + await categoryQuery.preload() + + return json({ + category, + dbState: dbScope.serialize(), + }) + } finally { + await dbScope.cleanup() + } +} + +export default function CatalogCategoryRoute() { + const { dbState, category } = useLoaderData() + + // This ProvideDbScope nests inside the parent route's ProvideDbScope. + // On the client, state merges: useDbScope() in descendants sees both + // the parent's account/catalog data and this route's category data. + return ( + + + + ) +} + +function CategoryView({ category }: { category: string }) { + const scope = useDbScope() + const categoryQuery = getCatalogCategoryLiveQuery({ category }, scope) + const { data: items } = useLiveQuery(categoryQuery) + + return ( +
+

{category}

+
    + {items.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ) +} +``` + +## How Merge Works + +The component tree during SSR looks like: + +``` + ← parent route + + + ← child route + + + + +``` + +On the client: + +1. The outer `ProvideDbScope` creates a scope hydrated from `parentDbState`. +2. The inner `ProvideDbScope` merges `childDbState` into the parent scope. +3. `useDbScope()` inside `CategoryView` returns the merged scope. +4. Collection snapshots are deduplicated by `id`. On conflict, the entry with the later `generatedAt` timestamp wins; equal timestamps fall back to child-wins. +5. Live query payloads are deduplicated by `id`. On conflict, the entry with the later `updatedAt` timestamp wins; equal timestamps fall back to child-wins. +6. During initial SSR, timestamps are nearly identical so tree position (child wins) is the effective tiebreaker. During client-side transitions, timestamp comparison prevents stale cached parent data from overwriting fresher child data. + +## Notes + +1. Each loader owns its own scope lifecycle: create, serialize, cleanup. +2. The tradeoff is that collections used by both parent and child loaders are separate instances on the server (different scopes), so data may be fetched twice. This is acceptable when loaders run in parallel and cannot share state. +3. On the client, memoization uses the nearest scope, so collection instances are shared within the merged subtree. diff --git a/SSR_ALT_TANSTACK_START_EXAMPLE.md b/SSR_ALT_TANSTACK_START_EXAMPLE.md new file mode 100644 index 000000000..8cec97147 --- /dev/null +++ b/SSR_ALT_TANSTACK_START_EXAMPLE.md @@ -0,0 +1,265 @@ +# TanStack Start Example (Alt API): Single Root Scope + +This example uses the preferred single root scope strategy: + +1. Create scope once per request in `createRouter()`. +2. All loaders share the scope via router context. +3. Root component serializes during render (after all loaders complete). +4. Middleware handles cleanup. + +## 1) Shared Getters + +```ts +// src/db/getters.ts +import type { DbScope } from '@tanstack/db/ssr' +import { defineCollection, defineLiveQuery } from '@tanstack/db/ssr' +import { eq, liveQueryCollectionOptions } from '@tanstack/db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' + +export const getCatalogCollection = defineCollection((scope?: DbScope) => + queryCollectionOptions({ + id: `catalog`, + queryKey: [`catalog`], + queryFn: fetchCatalogRows, + getKey: (row) => row.id, + }), +) + +export const getAccountCollection = defineCollection( + ({ userId }: { userId: string }, scope: DbScope) => + queryCollectionOptions({ + id: `account:${userId}`, + queryKey: [`account`, userId], + queryFn: () => fetchAccountRows(userId), + getKey: (row) => row.id, + }), + { scope: 'required' }, +) + +export const getCatalogGridLiveQuery = defineLiveQuery((scope?: DbScope) => + liveQueryCollectionOptions({ + id: `catalog-grid`, + query: (q) => q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), + ssr: { serializes: true }, + }), +) + +export const getAccountSummaryLiveQuery = defineLiveQuery( + ({ userId }: { userId: string }, scope: DbScope) => + liveQueryCollectionOptions({ + id: `account-summary:${userId}`, + query: (q) => q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), + ssr: { serializes: true }, + }), + { scope: 'required' }, +) + +export const getCatalogCategoryLiveQuery = defineLiveQuery( + ({ category }: { category: string }, scope?: DbScope) => + liveQueryCollectionOptions({ + id: `catalog-category:${category}`, + query: (q) => + q + .from({ c: getCatalogCollection(scope) }) + .where(({ c }) => eq(c.category, category)), + ssr: { serializes: true }, + }), +) +``` + +## 2) Router Creation + +```ts +// src/router.tsx +import { QueryClient } from '@tanstack/react-query' +import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { createDbScope } from '@tanstack/db/ssr' +import { routeTree } from './routeTree.gen' + +export function createRouter() { + const queryClient = new QueryClient() + const dbScope = createDbScope() + + return createTanStackRouter({ + routeTree, + context: { + queryClient, + dbScope, + }, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} +``` + +TanStack Start calls `createRouter()` per server request, so `dbScope` is request-scoped automatically. + +## 3) Middleware: Cleanup After Response + +```ts +// src/start/middleware/db-scope.ts +import { createMiddleware } from '@tanstack/start' + +export const dbScopeMiddleware = createMiddleware().server(async ({ next, context }) => { + try { + return await next() + } finally { + await context.dbScope.cleanup() + } +}) +``` + +Cleanup runs after the full response lifecycle, not inside individual loaders. + +## 4) Root Route: Serialize and Provide + +```tsx +// src/routes/__root.tsx +import { createRootRouteWithContext } from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-query' +import type { DbScope } from '@tanstack/db/ssr' +import { ProvideDbScope } from '@tanstack/react-db/ssr' + +interface RouterContext { + queryClient: QueryClient + dbScope: DbScope +} + +export const Route = createRootRouteWithContext()({ + component: RootComponent, +}) + +function RootComponent() { + const { dbScope } = Route.useRouteContext() + + // During SSR, all matched loaders have completed before this renders. + // serialize() captures the fully-populated scope. + const dbState = dbScope.serialize() + + return ( + + + + ) +} +``` + +## 5) Route Loader: Use Shared Scope + +```tsx +// src/routes/store.tsx +import { createFileRoute } from '@tanstack/react-router' +import { getWebRequest } from '@tanstack/start' +import { + getAccountCollection, + getAccountSummaryLiveQuery, + getCatalogGridLiveQuery, +} from '@/db/getters' +import { StoreView } from './store-view' + +export const Route = createFileRoute(`/store`)({ + loader: async ({ context }) => { + const { dbScope } = context + const request = getWebRequest() + const userId = getUserIdFromRequest(request) + + // Collections are memoized per scope + params. + // Multiple loaders using the same scope share instances. + const accountCollection = getAccountCollection({ userId }, dbScope) + const catalogGrid = getCatalogGridLiveQuery(dbScope) + const accountSummary = getAccountSummaryLiveQuery({ userId }, dbScope) + + await Promise.all([catalogGrid.preload(), accountSummary.preload()]) + + dbScope.include(accountCollection) + + // Return application data only. No dbState here. + return { userId } + }, + component: StoreRouteComponent, +}) + +function StoreRouteComponent() { + const { userId } = Route.useLoaderData() + + // No ProvideDbScope needed here. Root provides it. + return +} +``` + +## 6) Child Route Loader + +```tsx +// src/routes/store.$category.tsx +import { createFileRoute } from '@tanstack/react-router' +import { getCatalogCategoryLiveQuery } from '@/db/getters' + +export const Route = createFileRoute(`/store/$category`)({ + loader: async ({ context, params }) => { + const { dbScope } = context + const category = params.category ?? `all` + + // Uses the same shared scope. getCatalogCollection(scope) inside + // getCatalogCategoryLiveQuery returns the same memoized instance + // that the parent loader already created. + const categoryQuery = getCatalogCategoryLiveQuery({ category }, dbScope) + await categoryQuery.preload() + + return { category } + }, + component: CategoryRouteComponent, +}) + +function CategoryRouteComponent() { + const { category } = Route.useLoaderData() + + // No ProvideDbScope needed here either. + return +} +``` + +## 7) Client Views + +```tsx +// src/routes/store-view.tsx +import { useLiveQuery } from '@tanstack/react-db' +import { useDbScope } from '@tanstack/react-db/ssr' +import { + getAccountSummaryLiveQuery, + getCatalogGridLiveQuery, +} from '@/db/getters' + +export function StoreView({ userId }: { userId: string }) { + const scope = useDbScope() + const catalogGrid = getCatalogGridLiveQuery(scope) + const accountSummary = getAccountSummaryLiveQuery({ userId }, scope) + + const { data: catalog } = useLiveQuery(catalogGrid) + const { data: account } = useLiveQuery(accountSummary) + + return ( +
+

Store

+

{account?.email}

+
    + {catalog.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ) +} +``` + +## Why Single Root Scope Works Here + +1. `createRouter()` runs once per server request, so `dbScope` is naturally request-scoped. +2. Router context is available to both loaders and components. +3. All matched loaders complete before the component tree renders during SSR. +4. `serialize()` in the root component captures everything every loader contributed. +5. Memoization deduplicates: if parent and child loaders both call `getCatalogCollection(dbScope)`, they get the same instance and the data is fetched once. +6. Cleanup in middleware runs after the full render lifecycle. diff --git a/SSR_DESIGN.md b/SSR_DESIGN.md new file mode 100644 index 000000000..cfacc8cd1 --- /dev/null +++ b/SSR_DESIGN.md @@ -0,0 +1,485 @@ +# SSR Design: Request and Process Scoped Collections with Cross-Framework Hydration + +Status: Draft proposal +Target: `@tanstack/db` core + framework adapters (`@tanstack/react-db` first) +Related: issue #545, draft PR #709 + +## Problem Statement + +The previous SSR proposal hydrated `useLiveQuery` results by query id, but did not fully address server isolation: + +1. SSR queries can capture module-scoped collection instances. +2. Query-backed collections can share module-scoped `QueryClient` instances. +3. Server module state can survive across requests. +4. Result: request data leakage risk. + +At the same time, some data should intentionally be process-cached for fast responses (for example, store catalog data). + +We need a design that: + +1. Uses request scope by default for correctness. +2. Allows process-scoped collections intentionally for safe shared caches. +3. Supports query hydration and optional collection hydration. +4. Works across Next.js, TanStack Start, and React Router / Remix. +5. Shares implementation in core so all framework adapters follow the same behavior. + +## Goals + +1. Make request-scoped server collections the default SSR pattern. +2. Support intentional process-scoped collections for cacheable non-request data. +3. Keep query-result hydration (`prefetchLiveQuery`) for small payloads and fast first paint. +4. Support explicit live query serialization that skips collection used-marking for that query. +5. Add optional collection snapshot serialization/hydration. +6. Include sync metadata in snapshots so resumable sync engines can continue from hydrated state. +7. Define fallback behavior for non-resumable sync engines using truncate-and-restart. +8. Keep API TanStack-like: explicit, composable, type-safe, no implicit global request state. + +## Non-Goals + +1. Full streaming/Suspense SSR in v1. +2. Automatic serialization of all collections. +3. Implicit framework-specific request context resolution inside core. + +## Design Principles + +1. Correctness by default: request scope is the default. +2. Explicit escape hatch: process scope must be intentional and declared. +3. One snapshot API for all collection types, including live query collections. +4. Collection serialization is opt-in and allowlisted. +5. Core owns SSR data model and lifecycle; adapters own framework integration ergonomics. + +## Proposed Architecture + +### 1. Scope Model: `request` and `process` + +Every server collection used in SSR has an explicit scope: + +1. `request`: unique instance per request, cleaned up at request end. +2. `process`: shared instance across requests in the same process/runtime. + +`request` is default when not declared. + +`process` is for data that is safe to share (for example, public catalog data). It should not depend on per-user auth or request-local headers. + +Recommended process-scope use cases: + +1. Public product catalog and static merchandising data. +2. Read-mostly lookup tables with long cache lifetimes. +3. Data that is explicitly safe to share across users. + +### 1.1 Process Scope via Server Globals + +Process scope can be implemented as server globals for convenience. + +Proposed convenience pattern: + +```ts +// app/db/server-shared.ts +import { createDbSharedEnvironment } from '@tanstack/db/ssr' + +const DB_SHARED_ENV_KEY = Symbol.for(`@tanstack/db/ssr-shared-env`) +const globalStore = globalThis as Record + +export const sharedDbEnv = (globalStore[DB_SHARED_ENV_KEY] ??= + createDbSharedEnvironment()) as ReturnType +``` + +This should be supported as a first-class path for process-scoped collections. + +Bundling/runtime caveats: + +1. Scope is per JS runtime realm, not globally shared across all server machines. +2. Different processes/workers/isolates each get their own process cache. +3. In serverless/edge, warm instance reuse determines cache persistence. +4. Module duplication can break module-level singletons; `globalThis + Symbol.for` avoids duplication within one realm. +5. Different runtime entry points (for example middleware runtime vs app server runtime) may not share a realm, so process scope should be treated as best-effort shared cache, not distributed cache. + +Design implication: + +1. Keep explicit `DbSharedEnvironment` injection for deterministic integration tests and advanced setups. +2. Provide official helper for global-backed shared env to keep app ergonomics simple. + +### 2. Core SSR Primitives in `@tanstack/db` + +Add framework-agnostic SSR APIs in core (illustrative names): + +```ts +// @tanstack/db/ssr +type ServerCollectionScope = `request` | `process` + +export interface DbSharedEnvironment { + getOrCreate(key: string, factory: () => T): T +} + +export interface DbRequestScope { + readonly id: string + readonly collections: TCollections + readonly collectionScopes: Partial> + readonly prefetchedQueries: Map + cleanup(): Promise +} + +export interface CreateDbRequestScopeOptions { + shared?: DbSharedEnvironment + createCollections: (ctx: { shared: DbSharedEnvironment }) => TCollections + collectionScopes?: Partial> + cleanupCollections?: (collections: TCollections) => Promise | void +} + +export function createDbSharedEnvironment(): DbSharedEnvironment + +export function createDbRequestScope( + options: CreateDbRequestScopeOptions, +): DbRequestScope +``` + +Lifecycle behavior: + +1. `scope.cleanup()` cleans up `request` collections by default. +2. `process` collections are not cleaned up per request. +3. Custom `cleanupCollections` can override default behavior. + +### 3. Request Scope Query Prefetch + +```ts +export interface PrefetchDbQueryOptions< + TCollections extends CollectionMap, + TContext extends Context, +> { + id: string + query: (args: { + q: InitialQueryBuilder + collections: TCollections + }) => QueryBuilder + transform?: (rows: Array>) => unknown + ssr?: { + explicitlySerialized?: boolean // default false + } +} + +export async function prefetchDbQuery< + TCollections extends CollectionMap, + TContext extends Context, +>( + scope: DbRequestScope, + options: PrefetchDbQueryOptions, +): Promise +``` + +Important difference from the draft PR: + +1. Prefetch query receives request scope collections explicitly. +2. This avoids hidden module singleton capture in server prefetch paths. + +### 3.1 Explicit Query Serialization Mode + +When `ssr.explicitlySerialized` is `true` on a prefetched live query: + +1. Query result is marked for dehydration by query id. +2. Source collections referenced by that query are not auto-marked used. +3. This avoids duplicate payloads (query result + same collection snapshots) unless collections are include-listed explicitly. + +### 4. Dehydrated State Format + +Use a versioned payload: + +```ts +export interface DehydratedDbStateV1 { + version: 1 + queries: Record< + string, + { + data: unknown + timestamp: number + } + > + collections?: Record< + string, + { + snapshot: CollectionSnapshot + timestamp: number + scope: `request` | `process` + } + > +} +``` + +### 5. Optional Collection Serialization + +Add explicit options to `dehydrateDbScope`: + +```ts +export type CollectionSelector = + | Array + | ((entry: { + name: keyof TCollections + collection: TCollections[keyof TCollections] + scope: `request` | `process` + }) => boolean) + +export interface DehydrateDbScopeOptions { + includeQueries?: boolean // default true + includeCollections?: + | false + | { + include: CollectionSelector + awaitReady?: boolean // default false + includeSyncState?: boolean // default true + transform?: Partial< + Record< + keyof TCollections, + (snapshot: CollectionSnapshot) => CollectionSnapshot + > + > + } +} +``` + +Behavior: + +1. Default is query-only dehydration. +2. Collection dehydration is opt-in and allowlisted. +3. `include` can filter by name and scope. +4. `includeSyncState` controls whether snapshot sync metadata is serialized. +5. `includeQueries: true` serializes prefetched queries, including queries with `ssr.explicitlySerialized: true`. +6. Explicit query serialization does not imply collection snapshot serialization. + +### 6. Snapshot API for Collections and Live Query Collections + +Because live query collections are collections, we keep a single API: + +```ts +interface CollectionSnapshot { + rows: Array + metadata?: { + exportedAt: number + syncState?: TSyncState + syncStateVersion?: number | string + } +} + +interface Collection { + exportSnapshot(options?: { + includeSyncState?: boolean // default true + }): CollectionSnapshot + + importSnapshot( + snapshot: CollectionSnapshot, + options?: { + replace?: boolean // default true + applySyncState?: `resume-if-possible` | `ignore` | `require-resume` + onIncompatibleSyncState?: `truncate` | `ignore` | `throw` // default truncate + }, + ): { resumed: boolean } +} +``` + +`importSnapshot` requirements: + +1. Writes to synced/base state, not optimistic layer. +2. Does not trigger persistence handlers. +3. Emits normal reactive updates. +4. Works for base collections and live query collections. + +### 7. Sync Metadata and Resume Semantics + +Add optional hooks for sync adapters: + +```ts +interface SyncConfig { + exportSyncState?: () => unknown + importSyncState?: (state: unknown) => { resumed: boolean } +} +``` + +Import behavior: + +1. If `importSyncState` is present and accepts metadata, sync can resume from hydrated position. +2. If metadata is missing or incompatible and `onIncompatibleSyncState` is `truncate`, a truncate-style restart is triggered before fresh sync. +3. If sync metadata is unsupported, adapters should behave as non-resumable and follow restart policy. + +Truncate restart intent: + +1. Clear potentially stale hydrated rows. +2. Rebuild from authoritative upstream sync path. +3. Keep behavior deterministic across adapters. + +### 8. Framework Adapter Layer + +React adapter (`@tanstack/react-db`) wraps the core primitives: + +```ts +// server +createServerContext(...) +prefetchLiveQuery(...) +dehydrate(...) + +// client +HydrationBoundary +useHydratedQuery(id) +useLiveQuery({ id, query, ssr: { explicitlySerialized: true } }) +useHydrateCollections(...) +``` + +`useLiveQuery` behavior when a server scope exists: + +1. Default mode auto-marks scope collections used by the query graph. +2. `ssr.explicitlySerialized: true` marks only the live query id for dehydration and skips collection used-marking for that query. + +Other adapters (Solid/Vue/Svelte) use the same core SSR primitives and provide framework-idiomatic boundary/hydration wrappers only. + +No framework should re-implement dehydration, snapshot format, or resume semantics. + +## Request Lifecycle + +Server: + +1. Build request scope with request and optional process collections. +2. Prefetch query ids against scope collections and mark explicit-query serialization where needed. +3. Optionally dehydrate collection snapshots for used/include-listed collections. +4. Render with dehydrated state. +5. Cleanup request scope in `finally`. + +Client: + +1. Build client collections. +2. Provide dehydrated payload via framework boundary/provider. +3. Hydrate query ids for `useLiveQuery({ id })`. +4. Optionally apply collection snapshots. +5. Resume sync if sync metadata is compatible; otherwise restart based on policy. + +## App Collection Factory Pattern + +Recommended pattern: + +```ts +// app/db/createServerCollections.ts +export function createServerCollections(ctx: { + request: RequestLike + shared: DbSharedEnvironment +}) { + const sharedQueryClient = ctx.shared.getOrCreate( + `catalog-query-client`, + () => new QueryClient(), + ) + const catalogCollection = ctx.shared.getOrCreate(`catalog-collection`, () => + createCollection(...), + ) + + const requestQueryClient = new QueryClient() + const accountCollection = createCollection(...) + + return { + catalog: catalogCollection, // process + account: accountCollection, // request + } +} +``` + +Then bind scopes explicitly in request creation: + +```ts +const scope = createDbRequestScope({ + shared: sharedEnv, + createCollections: ({ shared }) => createServerCollections({ request, shared }), + collectionScopes: { + catalog: `process`, + account: `request`, + }, +}) +``` + +## Framework Example Docs + +Detailed example docs are included next to this design doc: + +1. `SSR_NEXTJS_EXAMPLE.md` +2. `SSR_TANSTACK_START_EXAMPLE.md` +3. `SSR_REACT_ROUTER_REMIX_EXAMPLE.md` + +Each example demonstrates: + +1. Mixed `request` and `process` scope usage. +2. Query dehydration and optional collection snapshot dehydration. +3. Sync metadata resume behavior with truncate fallback. + +## Backwards Compatibility + +1. Keep draft-style `prefetchLiveQuery(serverContext, { id, query })` for one cycle. +2. Mark it as compatibility mode, with docs warning about singleton capture risk. +3. Add dev warnings when collection scope is unknown in server prefetch paths. +4. Move official docs and examples to explicit scope APIs immediately. + +## Security and Payload Controls + +1. Collection serialization defaults to disabled. +2. Serialization requires explicit allowlisting. +3. Recommend redaction transforms for sensitive fields. +4. Include per-route payload budgeting guidance. +5. Encourage process scope only for non-sensitive shareable data. + +## Failure and Cleanup Semantics + +1. Scope cleanup runs in `finally`. +2. Cleanup failures are aggregated and logged in dev. +3. Prefetch failures fail request by default unless framework route chooses partial fallback. +4. `truncate` policy clears and restarts sync when resume metadata is incompatible. +5. `throw` policy fails early when resume metadata is incompatible. +6. `ignore` policy keeps hydrated rows until normal sync overwrites them. + +## Testing Plan + +### Unit Tests (core) + +1. Scope map correctness (`request` default, `process` explicit). +2. Request cleanup does not dispose process collections. +3. Snapshot export/import includes rows and optional sync metadata. +4. Resume path (`importSyncState` success) and truncate fallback path. +5. Live query collection snapshot parity with base collections. +6. `ssr.explicitlySerialized` queries skip collection used-marking. + +### Integration Tests (react-db) + +1. Query hydration by id with mixed scope collections. +2. Collection snapshot hydration path. +3. Two-request isolation test for request collections. +4. Positive test for process-scoped catalog sharing with no request leakage. +5. Framework-style tests for Next.js, Start, and React Router loader pipelines. +6. Explicit live query serialization avoids duplicate collection dehydration. + +### Regression Test for Existing Leak Class + +1. Request A: user 1, prefetch query key `['projects']`. +2. Request B: user 2, same query key. +3. Request scope pattern must never serve A data in B. +4. A deliberate singleton fixture should still demonstrate leak risk (guardrail test). + +## Rollout Plan + +### Phase 1 + +1. Land request/process scope model and query dehydration APIs in core. +2. React adapter integration for query hydration. +3. Publish migration docs for collection factory + explicit scope declaration. + +### Phase 2 + +1. Land `exportSnapshot` / `importSnapshot` in core collection API. +2. Land sync metadata resume hooks plus truncate fallback semantics. +3. Enable optional collection snapshot dehydration/hydration in React adapter. + +### Phase 3 + +1. Adapter parity for Solid/Vue/Svelte. +2. Add full framework example suites and e2e matrices. + +## Open Questions + +1. Should `replace` remain default for `importSnapshot`, or should merge be default? +2. Should strict JSON mode be opt-in or default in production builds? +3. How long should compatibility mode remain before deprecation warning escalation? +4. Should process scope include optional TTL/invalidation helpers in core? + +## Summary + +The design shifts SSR from "query hydration over implicit global collections" to "explicitly scoped server collections with shared core hydration semantics." It preserves request safety by default, allows intentional process-level cache patterns, and uses one snapshot contract for both collections and live query collections, including sync resume metadata and truncate fallback for non-resumable engines. diff --git a/SSR_NEXTJS_EXAMPLE.md b/SSR_NEXTJS_EXAMPLE.md new file mode 100644 index 000000000..024a4a11e --- /dev/null +++ b/SSR_NEXTJS_EXAMPLE.md @@ -0,0 +1,249 @@ +# Next.js Example: Request + Process Scoped DB SSR + +This file shows proposed usage of the APIs from `SSR_DESIGN.md`. + +## 1) Shared Server Environment + +```ts +// app/db/server-shared.ts +import { createDbSharedEnvironment } from '@tanstack/db/ssr' + +// One shared environment per server process/runtime instance. +export const sharedDbEnv = createDbSharedEnvironment() +``` + +Alternative global-backed shared environment (ergonomic process scope): + +```ts +// app/db/server-shared.ts +import { createDbSharedEnvironment } from '@tanstack/db/ssr' + +const DB_SHARED_ENV_KEY = Symbol.for(`@tanstack/db/ssr-shared-env`) +const globalStore = globalThis as Record + +export const sharedDbEnv = (globalStore[DB_SHARED_ENV_KEY] ??= + createDbSharedEnvironment()) as ReturnType +``` + +## 2) Server Collection Factory (Mixed Scope) + +```ts +// app/db/createServerCollections.ts +import { QueryClient } from '@tanstack/query-core' +import { createCollection } from '@tanstack/db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' + +type CreateServerCollectionsArgs = { + request: Request + shared: ReturnType +} + +export function createServerCollections({ request, shared }: CreateServerCollectionsArgs) { + const sharedQueryClient = shared.getOrCreate(`catalog-query-client`, () => new QueryClient()) + const catalogCollection = shared.getOrCreate(`catalog-collection`, () => + createCollection( + queryCollectionOptions({ + id: `catalog`, + queryKey: [`catalog`], + queryClient: sharedQueryClient, + queryFn: fetchCatalogRows, + getKey: (row) => row.id, + }), + ), + ) + + const requestQueryClient = new QueryClient() + const userId = getUserIdFromRequest(request) + const accountCollection = createCollection( + queryCollectionOptions({ + id: `account`, + queryKey: [`account`, userId], + queryClient: requestQueryClient, + queryFn: () => fetchAccountRows(userId), + getKey: (row) => row.id, + }), + ) + + return { + catalog: catalogCollection, // process scope + account: accountCollection, // request scope + } +} +``` + +## 3) Server Component Route (Prefetch + Dehydrate) + +```tsx +// app/store/page.tsx +import { + createDbRequestScope, + prefetchDbQuery, + dehydrateDbScope, +} from '@tanstack/db/ssr' +import { HydrationBoundary } from '@tanstack/react-db/hydration' +import { sharedDbEnv } from '@/db/server-shared' +import { createServerCollections } from '@/db/createServerCollections' +import { StorePageClient } from './StorePageClient' + +export default async function StorePage() { + const scope = createDbRequestScope({ + shared: sharedDbEnv, + createCollections: ({ shared }) => + createServerCollections({ request: getRequest(), shared }), + collectionScopes: { + catalog: `process`, + account: `request`, + }, + }) + + try { + await prefetchDbQuery(scope, { + id: `catalog-grid`, + query: ({ q, collections }) => + q.from({ c: collections.catalog }).orderBy(({ c }) => c.name), + ssr: { + explicitlySerialized: true, + }, + }) + + await prefetchDbQuery(scope, { + id: `account-summary`, + query: ({ q, collections }) => + q.from({ a: collections.account }).findOne(), + }) + + const dehydratedState = await dehydrateDbScope(scope, { + includeQueries: true, + includeCollections: { + // `catalog-grid` is explicitly serialized as a live query above. + // Only snapshot `account` collection state here. + include: [`account`], + includeSyncState: true, + }, + }) + + return ( + + + + ) + } finally { + await scope.cleanup() + } +} +``` + +## 4) Client Component (Hydrate Query + Optional Collection Snapshot) + +```tsx +'use client' + +import { useLiveQuery } from '@tanstack/react-db' +import { useHydrateCollections } from '@tanstack/react-db/hydration' +import { clientCollections } from '@/db/clientCollections' + +export function StorePageClient() { + // Proposed helper: apply snapshot rows + sync metadata once. + useHydrateCollections({ + collections: clientCollections, + }) + + const { data: catalog } = useLiveQuery({ + id: `catalog-grid`, + query: (q) => q.from({ c: clientCollections.catalog }), + ssr: { + explicitlySerialized: true, + }, + }) + + const { data: account } = useLiveQuery({ + id: `account-summary`, + query: (q) => q.from({ a: clientCollections.account }).findOne(), + }) + + return ( +
+

Store

+

{account?.email}

+
    + {catalog.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ) +} +``` + +## 5) Resume Metadata and Truncate Fallback + +Expected behavior after `useHydrateCollections`: + +1. If sync metadata is compatible, collection sync resumes from hydrated state. +2. If incompatible or missing, policy `onIncompatibleSyncState: 'truncate'` triggers a truncate-style restart and full refetch. + +## 5.1) Explicit Query Serialization Behavior + +In this example: + +1. `catalog-grid` uses `ssr.explicitlySerialized: true`, so its live query payload is dehydrated by query id. +2. That explicit mode skips auto-marking `catalog` collection as used for snapshot dehydration. +3. `account` still uses normal collection-used tracking and is snapshot dehydrated. + +## 6) Route Loader Style Example (Next.js Pages Router) + +Next.js App Router does not expose a dedicated route `loader` API. The loader-style equivalent is `getServerSideProps` in Pages Router. + +```tsx +// pages/store.tsx +import type { GetServerSideProps } from 'next' +import { + createDbRequestScope, + prefetchDbQuery, + dehydrateDbScope, +} from '@tanstack/db/ssr' +import { HydrationBoundary } from '@tanstack/react-db/hydration' +import { sharedDbEnv } from '@/db/server-shared' +import { createServerCollections } from '@/db/createServerCollections' + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const scope = createDbRequestScope({ + shared: sharedDbEnv, + createCollections: ({ shared }) => + createServerCollections({ request: ctx.req as unknown as Request, shared }), + collectionScopes: { + catalog: `process`, + account: `request`, + }, + }) + + try { + await prefetchDbQuery(scope, { + id: `catalog-grid`, + query: ({ q, collections }) => + q.from({ c: collections.catalog }).orderBy(({ c }) => c.name), + ssr: { + explicitlySerialized: true, + }, + }) + + return { + props: { + dehydratedState: await dehydrateDbScope(scope, { + includeQueries: true, + }), + }, + } + } finally { + await scope.cleanup() + } +} + +export default function StorePage(props: { dehydratedState: unknown }) { + return ( + + + + ) +} +``` diff --git a/SSR_REACT_ROUTER_REMIX_EXAMPLE.md b/SSR_REACT_ROUTER_REMIX_EXAMPLE.md new file mode 100644 index 000000000..a232eb9ed --- /dev/null +++ b/SSR_REACT_ROUTER_REMIX_EXAMPLE.md @@ -0,0 +1,193 @@ +# React Router / Remix Example: Request + Process Scoped DB SSR + +This file shows proposed usage for React Router and Remix style loader contexts. + +## 1) Shared Environment + +```ts +// app/db/server-shared.ts +import { createDbSharedEnvironment } from '@tanstack/db/ssr' + +export const sharedDbEnv = createDbSharedEnvironment() +``` + +## 2) Build Request Context with DB Scope + +```ts +// app/server/context.ts +import { createDbRequestScope } from '@tanstack/db/ssr' +import { sharedDbEnv } from '@/db/server-shared' +import { createServerCollections } from '@/db/createServerCollections' + +export async function createRequestContext(request: Request) { + const dbScope = createDbRequestScope({ + shared: sharedDbEnv, + createCollections: ({ shared }) => + createServerCollections({ request, shared }), + collectionScopes: { + catalog: `process`, + account: `request`, + }, + }) + + return { dbScope } +} +``` + +For Remix this can be wired through `getLoadContext`. +For React Router this can be attached through middleware/context APIs. + +## 2.1) Remix `getLoadContext` Example + +```ts +// server.ts (Remix adapter entry) +import { createRequestContext } from '@/server/context' + +export default createRequestHandler({ + build, + mode: process.env.NODE_ENV, + getLoadContext: async (req) => { + return createRequestContext(req) + }, +}) +``` + +## 3) Loader Prefetch + Dehydrate + +```ts +// app/routes/store.tsx +import { + prefetchDbQuery, + dehydrateDbScope, +} from '@tanstack/db/ssr' + +export async function loader({ context }: LoaderFunctionArgs) { + const { dbScope } = context + + try { + await prefetchDbQuery(dbScope, { + id: `catalog-grid`, + query: ({ q, collections }) => + q.from({ c: collections.catalog }).orderBy(({ c }) => c.name), + ssr: { + explicitlySerialized: true, + }, + }) + + await prefetchDbQuery(dbScope, { + id: `account-summary`, + query: ({ q, collections }) => + q.from({ a: collections.account }).findOne(), + }) + + return json({ + dehydratedState: await dehydrateDbScope(dbScope, { + includeQueries: true, + includeCollections: { + // `catalog-grid` is explicitly serialized as a live query above. + // Only snapshot `account` collection state here. + include: [`account`], + includeSyncState: true, + }, + }), + }) + } finally { + await dbScope.cleanup() + } +} +``` + +## 4) Route Component + +```tsx +// app/routes/store.tsx +import { HydrationBoundary } from '@tanstack/react-db/hydration' +import { useLoaderData } from 'react-router' + +export default function StoreRoute() { + const { dehydratedState } = useLoaderData() + return ( + + + + ) +} +``` + +## 5) Client View + +```tsx +// app/components/store-view.tsx +import { useLiveQuery } from '@tanstack/react-db' +import { useHydrateCollections } from '@tanstack/react-db/hydration' +import { clientCollections } from '@/db/clientCollections' + +export function StoreView() { + useHydrateCollections({ + collections: clientCollections, + }) + + const { data: catalog } = useLiveQuery({ + id: `catalog-grid`, + query: (q) => q.from({ c: clientCollections.catalog }), + ssr: { + explicitlySerialized: true, + }, + }) + + const { data: account } = useLiveQuery({ + id: `account-summary`, + query: (q) => q.from({ a: clientCollections.account }).findOne(), + }) + + return ( +
+

Store

+

{account?.email}

+
    + {catalog.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ) +} +``` + +## 6) Resume and Restart Behavior + +After snapshot hydration: + +1. If sync metadata is usable, sync resumes from that metadata. +2. If metadata is unusable or missing, policy `onIncompatibleSyncState: 'truncate'` triggers restart from a clean synced state. + +## 6.1) Explicit Query Serialization Behavior + +In this example: + +1. `catalog-grid` sets `ssr.explicitlySerialized: true`, so that live query is dehydrated directly. +2. That mode skips auto-marking `catalog` collection as used from this query. +3. `account` remains collection-driven and is snapshot dehydrated. + +## 7) React Router Data Route Loader Example + +```ts +// app/routes/catalog.tsx (React Router) +import { eq } from '@tanstack/db' + +export async function loader({ context, params }: LoaderFunctionArgs) { + const { dbScope } = context + + await prefetchDbQuery(dbScope, { + id: `catalog-category-${params.category ?? `all`}`, + query: ({ q, collections }) => { + const base = q.from({ c: collections.catalog }) + return params.category + ? base.where(({ c }) => eq(c.category, params.category)) + : base + }, + }) + + return null +} +``` diff --git a/SSR_TANSTACK_START_EXAMPLE.md b/SSR_TANSTACK_START_EXAMPLE.md new file mode 100644 index 000000000..0ca06d75d --- /dev/null +++ b/SSR_TANSTACK_START_EXAMPLE.md @@ -0,0 +1,175 @@ +# TanStack Start Example: Request + Process Scoped DB SSR + +This file shows proposed usage for TanStack Start with the same core SSR APIs. + +## 1) Shared Environment + +```ts +// src/db/server-shared.ts +import { createDbSharedEnvironment } from '@tanstack/db/ssr' + +export const sharedDbEnv = createDbSharedEnvironment() +``` + +## 2) Middleware: Create Request Scope and Store in Context + +```ts +// src/start/middleware/db-scope.ts +import { createMiddleware } from '@tanstack/start' +import { createDbRequestScope } from '@tanstack/db/ssr' +import { sharedDbEnv } from '@/db/server-shared' +import { createServerCollections } from '@/db/createServerCollections' + +export const dbScopeMiddleware = createMiddleware().server(async ({ request, next }) => { + const dbScope = createDbRequestScope({ + shared: sharedDbEnv, + createCollections: ({ shared }) => + createServerCollections({ request, shared }), + collectionScopes: { + catalog: `process`, + account: `request`, + }, + }) + + try { + return await next({ + context: { + dbScope, + }, + }) + } finally { + await dbScope.cleanup() + } +}) +``` + +## 3) Route Loader: Prefetch with Request Scope + +```tsx +// src/routes/store.tsx +import { + prefetchDbQuery, + dehydrateDbScope, +} from '@tanstack/db/ssr' +import { createFileRoute } from '@tanstack/react-router' +import { HydrationBoundary } from '@tanstack/react-db/hydration' + +export const Route = createFileRoute(`/store`)({ + loader: async ({ context }) => { + const { dbScope } = context + + await prefetchDbQuery(dbScope, { + id: `catalog-grid`, + query: ({ q, collections }) => + q.from({ c: collections.catalog }).orderBy(({ c }) => c.name), + ssr: { + explicitlySerialized: true, + }, + }) + + await prefetchDbQuery(dbScope, { + id: `account-summary`, + query: ({ q, collections }) => + q.from({ a: collections.account }).findOne(), + }) + + return { + dehydratedState: await dehydrateDbScope(dbScope, { + includeQueries: true, + includeCollections: { + // `catalog-grid` is explicitly serialized as a live query above. + // Only snapshot `account` collection state here. + include: [`account`], + includeSyncState: true, + }, + }), + } + }, + component: StoreRouteComponent, +}) + +function StoreRouteComponent() { + const { dehydratedState } = Route.useLoaderData() + return ( + + + + ) +} +``` + +## 3.1) Child Route Loader Example + +A nested route can prefetch only its own query ids while reusing the same request scope from middleware context. + +```tsx +// src/routes/store.$category.tsx +import { createFileRoute } from '@tanstack/react-router' +import { eq } from '@tanstack/db' +import { prefetchDbQuery } from '@tanstack/db/ssr' + +export const Route = createFileRoute(`/store/$category`)({ + loader: async ({ params, context }) => { + const { dbScope } = context + + await prefetchDbQuery(dbScope, { + id: `catalog-category-${params.category}`, + query: ({ q, collections }) => + q + .from({ c: collections.catalog }) + .where(({ c }) => eq(c.category, params.category)), + }) + + return null + }, + component: CategoryView, +}) +``` + +## 4) Client View + +```tsx +// src/routes/store-view.tsx +import { useLiveQuery } from '@tanstack/react-db' +import { useHydrateCollections } from '@tanstack/react-db/hydration' +import { clientCollections } from '@/db/clientCollections' + +export function StoreView() { + useHydrateCollections({ + collections: clientCollections, + }) + + const { data: catalog } = useLiveQuery({ + id: `catalog-grid`, + query: (q) => q.from({ c: clientCollections.catalog }), + ssr: { + explicitlySerialized: true, + }, + }) + + const { data: account } = useLiveQuery({ + id: `account-summary`, + query: (q) => q.from({ a: clientCollections.account }).findOne(), + }) + + return ( +
+

Store

+

{account?.email}

+
    + {catalog.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ) +} +``` + +## 5) Why This Works + +1. Request data (`account`) is isolated in request scope. +2. Shared read-mostly data (`catalog`) uses process scope. +3. `catalog-grid` uses `ssr.explicitlySerialized: true`, so query dehydration is used and `catalog` is not auto-marked used for snapshot dehydration. +4. Query and collection hydration reuse one cross-framework SSR core. +5. Sync metadata can resume when possible, else truncate-and-restart. From eaff54115f45fe0938b0e2d9a725cf857d8865b0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:30:26 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- SSR_ALT_NEXTJS_EXAMPLE.md | 11 +++---- SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md | 6 ++-- SSR_ALT_TANSTACK_START_EXAMPLE.md | 22 +++++++------ SSR_DESIGN.md | 15 ++++++--- SSR_NEXTJS_EXAMPLE.md | 19 ++++++++--- SSR_REACT_ROUTER_REMIX_EXAMPLE.md | 5 +-- SSR_TANSTACK_START_EXAMPLE.md | 45 +++++++++++++-------------- 7 files changed, 71 insertions(+), 52 deletions(-) diff --git a/SSR_ALT_NEXTJS_EXAMPLE.md b/SSR_ALT_NEXTJS_EXAMPLE.md index 74ad89466..0e68b39f0 100644 --- a/SSR_ALT_NEXTJS_EXAMPLE.md +++ b/SSR_ALT_NEXTJS_EXAMPLE.md @@ -66,7 +66,8 @@ export const getAccountCollection = defineCollection( export const getCatalogGridLiveQuery = defineLiveQuery((scope) => liveQueryCollectionOptions({ id: `catalog-grid`, - query: (q) => q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), + query: (q) => + q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), ssr: { serializes: true }, }), ) @@ -75,7 +76,8 @@ export const getAccountSummaryLiveQuery = defineLiveQuery( ({ userId }: { userId: string }, scope: DbScope) => liveQueryCollectionOptions({ id: `account-summary:${userId}`, - query: (q) => q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), + query: (q) => + q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), ssr: { serializes: true }, }), { scope: 'required' }, @@ -164,10 +166,7 @@ export function StorePageClient({ userId }: { userId: string }) { import type { GetServerSideProps } from 'next' import { createDbScope } from '@tanstack/db/ssr' import { ProvideDbScope } from '@tanstack/react-db/ssr' -import { - getAccountCollection, - getAccountSummaryLiveQuery, -} from '@/db/getters' +import { getAccountCollection, getAccountSummaryLiveQuery } from '@/db/getters' import { StorePageClient } from '@/app/store/StorePageClient' export const getServerSideProps: GetServerSideProps = async (ctx) => { diff --git a/SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md b/SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md index cd407f965..2038fb8dd 100644 --- a/SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md +++ b/SSR_ALT_REACT_ROUTER_REMIX_EXAMPLE.md @@ -41,7 +41,8 @@ export const getAccountCollection = defineCollection( export const getCatalogGridLiveQuery = defineLiveQuery((scope?: DbScope) => liveQueryCollectionOptions({ id: `catalog-grid`, - query: (q) => q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), + query: (q) => + q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), ssr: { serializes: true }, }), ) @@ -50,7 +51,8 @@ export const getAccountSummaryLiveQuery = defineLiveQuery( ({ userId }: { userId: string }, scope: DbScope) => liveQueryCollectionOptions({ id: `account-summary:${userId}`, - query: (q) => q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), + query: (q) => + q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), ssr: { serializes: true }, }), { scope: 'required' }, diff --git a/SSR_ALT_TANSTACK_START_EXAMPLE.md b/SSR_ALT_TANSTACK_START_EXAMPLE.md index 8cec97147..6fc04a2bf 100644 --- a/SSR_ALT_TANSTACK_START_EXAMPLE.md +++ b/SSR_ALT_TANSTACK_START_EXAMPLE.md @@ -39,7 +39,8 @@ export const getAccountCollection = defineCollection( export const getCatalogGridLiveQuery = defineLiveQuery((scope?: DbScope) => liveQueryCollectionOptions({ id: `catalog-grid`, - query: (q) => q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), + query: (q) => + q.from({ c: getCatalogCollection(scope) }).orderBy(({ c }) => c.name), ssr: { serializes: true }, }), ) @@ -48,7 +49,8 @@ export const getAccountSummaryLiveQuery = defineLiveQuery( ({ userId }: { userId: string }, scope: DbScope) => liveQueryCollectionOptions({ id: `account-summary:${userId}`, - query: (q) => q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), + query: (q) => + q.from({ a: getAccountCollection({ userId }, scope) }).findOne(), ssr: { serializes: true }, }), { scope: 'required' }, @@ -104,13 +106,15 @@ TanStack Start calls `createRouter()` per server request, so `dbScope` is reques // src/start/middleware/db-scope.ts import { createMiddleware } from '@tanstack/start' -export const dbScopeMiddleware = createMiddleware().server(async ({ next, context }) => { - try { - return await next() - } finally { - await context.dbScope.cleanup() - } -}) +export const dbScopeMiddleware = createMiddleware().server( + async ({ next, context }) => { + try { + return await next() + } finally { + await context.dbScope.cleanup() + } + }, +) ``` Cleanup runs after the full response lifecycle, not inside individual loaders. diff --git a/SSR_DESIGN.md b/SSR_DESIGN.md index cfacc8cd1..986c29b96 100644 --- a/SSR_DESIGN.md +++ b/SSR_DESIGN.md @@ -114,12 +114,16 @@ export interface DbSharedEnvironment { export interface DbRequestScope { readonly id: string readonly collections: TCollections - readonly collectionScopes: Partial> + readonly collectionScopes: Partial< + Record + > readonly prefetchedQueries: Map cleanup(): Promise } -export interface CreateDbRequestScopeOptions { +export interface CreateDbRequestScopeOptions< + TCollections extends CollectionMap, +> { shared?: DbSharedEnvironment createCollections: (ctx: { shared: DbSharedEnvironment }) => TCollections collectionScopes?: Partial> @@ -228,7 +232,9 @@ export interface DehydrateDbScopeOptions { transform?: Partial< Record< keyof TCollections, - (snapshot: CollectionSnapshot) => CollectionSnapshot + ( + snapshot: CollectionSnapshot, + ) => CollectionSnapshot > > } @@ -381,7 +387,8 @@ Then bind scopes explicitly in request creation: ```ts const scope = createDbRequestScope({ shared: sharedEnv, - createCollections: ({ shared }) => createServerCollections({ request, shared }), + createCollections: ({ shared }) => + createServerCollections({ request, shared }), collectionScopes: { catalog: `process`, account: `request`, diff --git a/SSR_NEXTJS_EXAMPLE.md b/SSR_NEXTJS_EXAMPLE.md index 024a4a11e..de8d7f064 100644 --- a/SSR_NEXTJS_EXAMPLE.md +++ b/SSR_NEXTJS_EXAMPLE.md @@ -35,11 +35,19 @@ import { queryCollectionOptions } from '@tanstack/query-db-collection' type CreateServerCollectionsArgs = { request: Request - shared: ReturnType + shared: ReturnType< + typeof import('@tanstack/db/ssr').createDbSharedEnvironment + > } -export function createServerCollections({ request, shared }: CreateServerCollectionsArgs) { - const sharedQueryClient = shared.getOrCreate(`catalog-query-client`, () => new QueryClient()) +export function createServerCollections({ + request, + shared, +}: CreateServerCollectionsArgs) { + const sharedQueryClient = shared.getOrCreate( + `catalog-query-client`, + () => new QueryClient(), + ) const catalogCollection = shared.getOrCreate(`catalog-collection`, () => createCollection( queryCollectionOptions({ @@ -210,7 +218,10 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const scope = createDbRequestScope({ shared: sharedDbEnv, createCollections: ({ shared }) => - createServerCollections({ request: ctx.req as unknown as Request, shared }), + createServerCollections({ + request: ctx.req as unknown as Request, + shared, + }), collectionScopes: { catalog: `process`, account: `request`, diff --git a/SSR_REACT_ROUTER_REMIX_EXAMPLE.md b/SSR_REACT_ROUTER_REMIX_EXAMPLE.md index a232eb9ed..67a0c948e 100644 --- a/SSR_REACT_ROUTER_REMIX_EXAMPLE.md +++ b/SSR_REACT_ROUTER_REMIX_EXAMPLE.md @@ -56,10 +56,7 @@ export default createRequestHandler({ ```ts // app/routes/store.tsx -import { - prefetchDbQuery, - dehydrateDbScope, -} from '@tanstack/db/ssr' +import { prefetchDbQuery, dehydrateDbScope } from '@tanstack/db/ssr' export async function loader({ context }: LoaderFunctionArgs) { const { dbScope } = context diff --git a/SSR_TANSTACK_START_EXAMPLE.md b/SSR_TANSTACK_START_EXAMPLE.md index 0ca06d75d..90fbc7aee 100644 --- a/SSR_TANSTACK_START_EXAMPLE.md +++ b/SSR_TANSTACK_START_EXAMPLE.md @@ -20,37 +20,36 @@ import { createDbRequestScope } from '@tanstack/db/ssr' import { sharedDbEnv } from '@/db/server-shared' import { createServerCollections } from '@/db/createServerCollections' -export const dbScopeMiddleware = createMiddleware().server(async ({ request, next }) => { - const dbScope = createDbRequestScope({ - shared: sharedDbEnv, - createCollections: ({ shared }) => - createServerCollections({ request, shared }), - collectionScopes: { - catalog: `process`, - account: `request`, - }, - }) - - try { - return await next({ - context: { - dbScope, +export const dbScopeMiddleware = createMiddleware().server( + async ({ request, next }) => { + const dbScope = createDbRequestScope({ + shared: sharedDbEnv, + createCollections: ({ shared }) => + createServerCollections({ request, shared }), + collectionScopes: { + catalog: `process`, + account: `request`, }, }) - } finally { - await dbScope.cleanup() - } -}) + + try { + return await next({ + context: { + dbScope, + }, + }) + } finally { + await dbScope.cleanup() + } + }, +) ``` ## 3) Route Loader: Prefetch with Request Scope ```tsx // src/routes/store.tsx -import { - prefetchDbQuery, - dehydrateDbScope, -} from '@tanstack/db/ssr' +import { prefetchDbQuery, dehydrateDbScope } from '@tanstack/db/ssr' import { createFileRoute } from '@tanstack/react-router' import { HydrationBoundary } from '@tanstack/react-db/hydration'