Status: Draft proposal
Target: @tanstack/db core + framework adapters
Replaces: prior high-ceremony SSR draft design
This alt design keeps five core primitives:
createDbScopeProvideDbScopeuseDbScopedefineCollectiondefineLiveQuery
It explicitly separates two concerns:
- Lifecycle binding: pass
scopeto a getter. - Transfer intent: call
scope.include(collection).
This revision addresses the recent review concerns:
- Silent missing-scope bugs: add required-scope getter mode and strict hook behavior.
- Memoization ambiguity: define stable cache key and miss/hit behavior.
- QueryClient lifecycle ambiguity: factory execution contract is explicit.
- Scope-threading risks: add strict runtime guard and optional scope hook.
- Live query prune semantics: define hydration timing guarantee.
- Sync resume gap: define v1 metadata shape and defer advanced policy knobs.
- Undefined dehydrated state: define
DehydratedDbStateV1. - RSC cleanup timing: define safe patterns and example guidance.
interface DbScope {
include(collection: Collection<any, any, any>): void
serialize(): DehydratedDbStateV1
cleanup(): Promise<void>
}
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.ElementHook semantics:
useDbScope()throws if no provider is active.useOptionalDbScope()is for mixed SSR/CSR trees where provider presence is conditional.
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<any, any, any, any> & { id: string },
TGetterOptions extends DefineGetterOptions | undefined = undefined,
>(
getOptions: (scope?: DbScope) => TOptions,
options?: TGetterOptions,
): TGetterOptions extends { scope: 'required' }
? (scope: DbScope) => InferCollectionFromOptions<TOptions>
: (scope?: DbScope) => InferCollectionFromOptions<TOptions>
declare function defineCollection<
TParams extends object,
TOptions extends CollectionConfig<any, any, any, any> & { id: string },
TGetterOptions extends DefineGetterOptions | undefined = undefined,
>(
getOptions: (params: TParams, scope?: DbScope) => TOptions,
options?: TGetterOptions,
): (
...args: DefineGetterCallArgs<TParams, TGetterOptions>
) => InferCollectionFromOptions<TOptions>declare function defineLiveQuery<
TOptions extends LiveQueryCollectionConfig<any, any> & { id: string },
TGetterOptions extends DefineGetterOptions | undefined = undefined,
>(
getOptions: (scope?: DbScope) => TOptions,
options?: TGetterOptions,
): TGetterOptions extends { scope: 'required' }
? (scope: DbScope) => LiveQueryCollectionFromOptions<TOptions>
: (scope?: DbScope) => LiveQueryCollectionFromOptions<TOptions>
declare function defineLiveQuery<
TParams extends object,
TOptions extends LiveQueryCollectionConfig<any, any> & { id: string },
TGetterOptions extends DefineGetterOptions | undefined = undefined,
>(
getOptions: (params: TParams, scope?: DbScope) => TOptions,
options?: TGetterOptions,
): (
...args: DefineGetterCallArgs<TParams, TGetterOptions>
) => LiveQueryCollectionFromOptions<TOptions>Behavior:
- Parameterless getters avoid the
({}, scope)pattern. scope: 'required'causes runtime throw when scope is missing.- Type signature for required-scope getters requires a
scopearg. - In dev mode, optional-scope getters called without scope while an active scope is detectable should emit a warning.
Memoization is per getter function identity.
Key:
scopeSlot: scope instance identity, or global slot when no scope.paramsKey: deterministic structural hash of params (see constraints below).getterId: identity of the returned getter function.
Rules:
- Cache key is
getterId + scopeSlot + paramsKey. getOptions(...)executes only on cache miss.- Cache hit returns the exact same collection/live-query instance.
- In dev mode, if the same memo key resolves to a different
options.id, throw.
Consequence:
- Getters are safe to call repeatedly in render.
- Factories that allocate resources (for example
QueryClient) do not run on hits.
Params must be plain objects with deterministically hashable values. The structural hash is computed as follows:
- Object keys are sorted lexicographically before hashing to ensure key-order independence.
- Supported value types:
string,number,boolean,null,Date,BigInt, plain objects, and arrays of supported types. Datevalues are hashed by their numeric timestamp (Date.getTime()).BigIntvalues are hashed by their string representation with a type prefix to avoid collision with numeric strings.undefinedvalues are treated as absent keys and excluded from the hash.Map,Set,RegExp, class instances, functions, andSymbolare not supported as param values.- Cyclic references are not supported.
- In dev mode, encountering an unsupported value type in params should throw with a descriptive error naming the offending key and type.
- 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.
- Passing
scopebinds instance lifecycle to request scope. - Passing
scopedoes not imply transfer. scope.include(collection)opts a collection into snapshot transfer.useLiveQuery(...)tracks live-query usage forssr.serializes, but does not callinclude(...)on source collections.
Two placement strategies are supported. The choice depends on framework capabilities.
One scope per request, shared across all loaders, serialized once by the root component during SSR render.
Flow:
- Create
dbScopeonce per request (router creation, middleware, or request context). - Pass
dbScopeto all loaders via framework context. - Each loader uses
dbScopeto create collections, preload, and callinclude(...). - After all loaders complete, the server begins rendering the component tree.
- Root component calls
dbScope.serialize()and renders<ProvideDbScope state={dbState}>. - Cleanup runs after render via middleware
finallyor router teardown.
Benefits:
- Collection instances are shared across loaders via memoization (same scope, same params = same instance).
- No duplicate data loading for collections used by multiple routes.
- Single
ProvideDbScopeat the root; no nesting needed. - Clean mental model: one scope = one request = one serialized payload.
Requirement:
- The framework must allow passing a live scope object to the root component during SSR render.
- 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).
Each loader creates its own scope, serializes independently, and its route component provides a ProvideDbScope. Nested providers merge state on the client.
Flow:
- Each loader creates its own
dbScope. - Loader preloads, calls
include(...), serializes, and cleans up infinally. - Each route component renders
<ProvideDbScope state={dbState}>. - Nested
ProvideDbScopecomponents merge their state into the parent scope on the client.
Benefits:
- Each loader is self-contained with clear ownership of create, serialize, cleanup.
- No coordination required between parallel loaders.
- Works in frameworks where scope objects cannot be passed from loaders to components (the only serializable output is
dbState).
Tradeoff:
- Collections used by multiple loaders are separate instances (different scopes), so data may be fetched more than once on the server.
- Multiple
ProvideDbScopeproviders in the component tree.
Use when: React Router / Remix (parallel loaders, serializable boundary), Next.js (per-page or per-server-component scope).
When ProvideDbScope is nested inside another ProvideDbScope, the inner provider's state merges into the scope visible to its descendants.
<ProvideDbScope state={parentDbState}>
{/* useDbScope() here sees parentDbState */}
<ProvideDbScope state={childDbState}>
{/* useDbScope() here sees parentDbState + childDbState merged */}
<View />
</ProvideDbScope>
</ProvideDbScope>Merge rules:
- Collection snapshots merge by
id. On conflict, the entry with the latergeneratedAttimestamp wins. If timestamps are equal, the child entry wins. - Live query payloads merge by
id. On conflict, the entry with the laterupdatedAttimestamp wins. If timestamps are equal, the child entry wins. - The merged scope is a new client-side scope. Getter memoization uses the nearest scope identity.
- Merge happens at provider mount time. It is not reactive to parent state changes after mount.
Freshness rationale:
- 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.
- 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.
generatedAtonDehydratedDbStateV1andupdatedAton individual live query entries provide the freshness signal. Both are set atserialize()time.
When nesting is not needed:
- Single root scope strategy uses one
ProvideDbScopeat the root. No merge required. - Next.js App Router typically has one
ProvideDbScopeper page server component. No nesting.
When nesting is expected:
- React Router / Remix with per-loader scopes and nested routes.
- Any layout where a parent route and child route each provide their own
dbState.
interface DehydratedDbStateV1 {
version: 1
generatedAt: number
collections: Array<{
id: string
rows: ReadonlyArray<unknown>
meta?: unknown
}>
liveQueries: Array<{
id: string
data: unknown
updatedAt: number
}>
}Notes:
- Only JSON-serializable data is allowed.
metais optional sync metadata for resume-capable collections.
ssr: { serializes: true } marks live query result transfer candidates.
At scope.serialize():
- Build
includedCollectionIdsfromscope.include(...). - Gather used or preloaded live queries with
ssr.serializes: true. - Compute each candidate's dependency collection ids.
- Skip candidate if all dependencies are covered by included collection snapshots.
- Otherwise include the live query payload.
Hydration timing guarantee:
ProvideDbScopeapplies transferred collection snapshots and live query payloads before first descendant render.- Derived live queries (for example
.findOne()) are available from hydrated sources on initial client render.
V1 behavior:
- Collection snapshots may include optional
meta. - If collection implementation supports resume from
meta, it may resume. - If metadata is missing or incompatible, collection should restart from truncate/reload.
Out of scope for this doc:
- Detailed
onIncompatibleSyncStatepolicy matrix. - Multi-backend sync adapters and migration tooling.
Those are a follow-up design phase.
Rules:
- A scope should be serialized once for a given response payload boundary.
cleanup()should run only after the framework is done using scope-owned resources.- In RSC render flows, do not rely on
finallyaroundreturn <JSX/>when passing livescopeobject unless framework guarantees post-render finalization hooks.
Cleanup by strategy:
- Single root scope: cleanup runs in middleware
finallyor router teardown, after the full response is sent. - Per-loader scope: cleanup runs in each loader's own
finallyblock, afterserialize().
Safe RSC default:
- Compute
const dbState = scope.serialize()before returning JSX. - Pass
statetoProvideDbScope. - Cleanup in
finally.
- Create
dbScopeincreateRouter()alongsideQueryClient. - Pass
dbScopethrough router context so all loaders access it viacontext.dbScope. - Each loader uses the shared scope: creates collections, preloads, calls
include(...). - Loaders return application data only (no
dbState). - Root component calls
dbScope.serialize()during render and provides<ProvideDbScope state={dbState}>. - Middleware
finallyhandles 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.
- Each loader creates its own
dbScope, preloads, serializes, and cleans up. - Each route component wraps its subtree in
<ProvideDbScope state={dbState}>. - 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.
- Each page server component creates its own
dbScope. - Serialize before returning JSX for safe cleanup timing.
- One
ProvideDbScopeper 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.
getServerSidePropscreatesdbScope, preloads, serializes, and cleans up.- Page component receives
dbStateas a prop and renders<ProvideDbScope state={dbState}>.
Single entry point per page, so scope placement is straightforward.
- Existing non-SSR global collection usage remains valid.
- SSR support is additive through
createDbScope+ProvideDbScope. - Prior experimental APIs from draft PR are superseded by this surface.
- Streaming-partial SSR state commits across multiple flush checkpoints.
- Advanced sync-state compatibility policies.
- Automatic ambient-scope inference on server via AsyncLocalStorage.
- Getter memoization hit/miss behavior and deterministic param hashing.
- Scope isolation across concurrent requests.
- Required-scope getter runtime/type behavior.
- Include-only transfer semantics for collections.
- Live-query candidate pruning correctness and order independence.
- Hydration timing guarantee for derived live queries.
- Cleanup behavior across framework adapter integration tests.
- Nested
ProvideDbScopemerge: child overrides parent by id. - Nested
ProvideDbScopemerge:useDbScope()returns merged scope. - Single root scope: shared memoization across loaders using same scope.
- Single root scope: serialize captures all loaders' contributions.
- Param hash canonicalization: key order independence (
{ a, b }equals{ b, a }). - Param hash canonicalization:
undefinedvalues excluded from hash. - Param hash:
Datehashed by timestamp,BigInthashed with type prefix. - Param hash: dev-mode throw on unsupported types (
Map,Set, functions,Symbol, cyclic references). - Param hash: nested objects and arrays produce stable keys.
- Param hash: type prefixes prevent cross-type collisions (for example
"1"vs1vs1n). - Merge freshness: entry with later timestamp wins over tree-position default.
- Merge freshness: equal timestamps fall back to child-wins.
- Merge freshness: client-side route transition with stale parent cache does not overwrite fresher child data.