TypeScript
Four packages: a typed client, a framework-agnostic reactive core, and Next.js and SvelteKit adapters with live queries and SSR handled.
@surrealguard/client
SurrealGuardClient extends the official surrealdb SDK's Surreal class, so you get every SDK method plus typed queries. Pass a string literal to query and its result and parameters resolve from the generated registry; a dynamic string falls back to the SDK's unknown[] and still runs. surrealdb is a peer dependency.
// One import: the generated file re-exports a ready client and loads the registry. import { SurrealGuardClient } from "./surrealguard.generated"; const db = new SurrealGuardClient(); await db.connect("ws://localhost:8000/rpc"); // SurrealDB returns one result per statement, so destructure the first result. const [users] = await db.query("SELECT name FROM user WHERE team = $team", { team: "red" }); // ^ Array<{ name: string }> — result & params inferred from the query text await db.query("SELECT name FROM user WHERE team = $team"); // ✗ Expected 2 arguments, but got 1. (params are required when the query reads them)
Because the client is a real Surreal, new SurrealGuardClient() then connect(), use(), live queries, and every other SDK method work exactly as the SDK documents. The SDK's fluent query builder resolves to the per-statement tuple, which is why you destructure the first statement's result.
The typing trick
Params are required exactly when the query reads them, forbidden otherwise. This is one conditional generic — ParamsArg<P> — proven against tsc. There is deliberately no permissive string overload for text: a second overload would defeat the inference, so a query text stays a single generic. The result narrows without lying about the runtime — the SDK's builder is nominal and unexported, so the override intersects it with Promise<tuple> to type what await yields while staying assignable to the base method.
type ParamsArg<P> = P extends Record<string, never> ? [] : [params: P]; query<Q extends string>(query: Q, ...args: ArgsOf<Q>): QueryBuilder & Promise<QueryResultOf<Q>>;
The package also exports RecordId<T>, GeoJSON, ArgsOf, QueryResultOf, SurqlRegistry, SurqlQueryShape, and ParamsArg.
Typed live queries
db.live(query) describes a live query and returns a typed LiveDescriptor<Row> the framework adapters consume; the row type resolves from the same registry as query. Pass the query as an argument (the parentheses) rather than a tagged template — a tagged template widens the text to string and loses the row type. The LIVE prefix is added for you, so db.live(`SELECT * FROM user`).sql is "LIVE SELECT * FROM user".
surrealguard generate
surrealguard generate scans your host files for embedded SurrealQL, analyzes each query against the schema, and emits one TypeScript file that re-exports a ready SurrealGuardClient and augments its SurqlRegistry — one entry per analyzed query, keyed by its exact text.
import type { RecordId, GeoJSON } from "@surrealguard/client"; export { SurrealGuardClient } from "@surrealguard/client"; declare module "@surrealguard/client" { interface SurqlRegistry { "SELECT * FROM user": { result: Array<{ id: RecordId<"user">; name: string }>; params: Record<string, never>; }; } }
Import SurrealGuardClient from this one generated file — that also loads the augmentation, so every matching db.query("…") across the app is typed with no separate side-import. The base SurqlRegistry is empty; the generated file only ever adds entries. Point --out at a .ts file (e.g. src/surrealguard.generated.ts); see the CLI for discovery.
@surrealguard/query
A framework-agnostic reactive core. A QueryClient owns a cache of results keyed by (sql, params); observe reference-counts subscriptions so N subscribers to the same query share one live subscription and one reconciled array.
import { QueryClient } from "@surrealguard/query"; import { SurrealGuardClient } from "./surrealguard.generated"; const db = new SurrealGuardClient(); await db.connect("ws://localhost:8000/rpc"); const qc = new QueryClient(db); // wraps a SurrealGuardClient const handle = qc.observe("LIVE SELECT * FROM user", { initialData }); const stop = handle.subscribe((state) => render(state.data));
- Dedup. Identical
(sql, params)calls share one cache entry via a stablequeryKey; the first subscriber starts it, the last to leave tears it down. - Live reconcile by id. A
LIVE SELECTseeds frominitialData(SSR), subscribes to the live-query id via the SDK, then applies change notifications to the array keyed by recordid—CREATEandUPDATEupsert,DELETEremoves. A plain query resolves once. - SSR.
dehydrate()snapshots successful cached results for transport;hydrate(state)seeds the client cache so the first render has no gap, then (for live queries) upgrades in place. fetch(sql, params)runs a one-shot query outside the reactive layer (e.g. in an SSRload).
State is { data: Row[], status: "loading" | "success" | "error", error? }. The framework packages are thin bindings over the Observable returned here.
@surrealguard/next
Provide the typed client once with <SurrealGuardProvider client={db}>, then call useLiveQuery in a client component. It passes the client to a callback that returns a db.live(...) descriptor — the row type is inferred from the query — and returns { data, status, error } backed by useSyncExternalStore. The reference-counted subscription is released on unmount.
// app/providers.tsx "use client"; import { SurrealGuardProvider } from "@surrealguard/next"; import { db } from "@/lib/db"; export function Providers({ children }) { return <SurrealGuardProvider client={db}>{children}</SurrealGuardProvider>; }
// app/users/users-list.tsx "use client"; import { useLiveQuery } from "@surrealguard/next"; function Users({ initialData }) { // db comes from context; the row type is inferred from the query. const { data, status } = useLiveQuery((db) => db.live(`SELECT * FROM user`), { initialData }); if (status === "loading") return <Spinner />; return data.map((u) => <li key={String(u.id)}>{u.name}</li>); }
For SSR/RSC, @surrealguard/next/server is server-safe: a Server Component runs queryServer(db, db.live(`…`)) once and passes the rows as initialData, so the first client render has no gap before it upgrades to live. db.live(...) takes the query as an argument (note the parentheses) so its literal type — and the row type — is preserved.
@surrealguard/svelte
Svelte 5 (runes) bindings. Provide the client once with setClient(db) in +layout.svelte, then call liveQuery with a callback that returns a db.live(...) descriptor. It returns a runes-reactive object you read directly — users.data, no store $ prefix — and the row type is inferred from the query. The subscription is reference-counted and torn down when the component is destroyed.
<script> import { liveQuery } from "@surrealguard/svelte"; // db comes from context (setClient in +layout.svelte) const users = liveQuery((db) => db.live(`SELECT * FROM user`)); </script> {#each users.data as user (user.id)} <li>{user.name}</li> // reconciled live: CREATE / UPDATE / DELETE by id {/each}
For gap-free SSR, seed from a load with loadLive(db, db.live(`…`)) and pass the rows as liveQuery(fn, { initial }); the client subscribes and upgrades to live in place. db.live(...) takes the query as an argument (note the parentheses) so its literal type — and the row type — is preserved.