Skip to Content
GuidesAdapter recipes

Adapter recipes — your data behind one fetch

Every contract needs exactly one integration point: a fetch that returns rows. The rule for all four recipes below is the same:

Map what’s cheap, skip the rest. Push eq/in filters, sort, and limit into your API when it’s a one-liner; return everything else as-is. The SDK’s engine re-applies the full query client-side, so over-returning is always safe — under-returning is not. (Declared execution: "server" capabilities are the exception: those your API must honor — probe them.)

Each recipe is a compiled, unit-tested file in examples/recipes — a sync test keeps this page honest.

REST

src/rest.ts
// REST recipe — the simplest honest adapter: list endpoint + auth header. // Push cheap narrowing (eq filters, limit) into query params when your API // supports them; return everything else as-is. Over-returning is safe — the // engine filters/sorts/groups client-side. Under-returning is not. import type { QuerySpec } from "@ticora/core"; export function restFetch(baseUrl: string) { return async ({ query, auth }: { query: QuerySpec; auth: unknown }) => { const url = new URL(`${baseUrl}/tickets`); for (const filter of query.filters) { // Only eq maps cleanly onto typical REST query params; the engine // re-applies every filter anyway, so skipping the rest is correct. if (filter.op === "eq") url.searchParams.set(filter.field, String(filter.value)); } if (query.limit) url.searchParams.set("limit", String(query.limit)); const response = await fetch(url, { headers: { authorization: `Bearer ${String(auth)}` }, }); if (!response.ok) throw new Error(`tickets API ${response.status}`); return (await response.json()) as unknown[]; }; }

GraphQL

src/graphql.ts
// GraphQL recipe — one fixed document, variables for the cheap narrowing. // Keep the document static (persisted-query friendly); the engine handles // whatever your schema can't express. import type { QuerySpec } from "@ticora/core"; const TICKETS_QUERY = /* GraphQL */ ` query Tickets($priority: String, $limit: Int) { tickets(priority: $priority, limit: $limit) { id subject priority assignee ageHours opened } } `; export function graphqlFetch(endpoint: string) { return async ({ query, auth }: { query: QuerySpec; auth: unknown }) => { const priority = query.filters.find( (filter) => filter.field === "priority" && filter.op === "eq", ); const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${String(auth)}`, }, body: JSON.stringify({ query: TICKETS_QUERY, variables: { priority: priority ? String(priority.value) : null, limit: query.limit ?? null, }, }), }); if (!response.ok) throw new Error(`GraphQL ${response.status}`); const payload = (await response.json()) as { data?: { tickets: unknown[] }; errors?: { message: string }[]; }; if (payload.errors?.length) throw new Error(payload.errors[0]!.message); return payload.data?.tickets ?? []; }; }

Prisma (server-side)

src/prisma.ts
// Prisma recipe — runs on YOUR server (a route handler or RPC), never in the // browser. Map eq/in filters and sort into the Prisma query; the engine // re-checks everything, so partial mapping is always safe. The structural // client type below is exactly the slice of PrismaClient the recipe touches — // substitute your generated client. import type { QuerySpec } from "@ticora/core"; export interface TicketDelegate { findMany(args: { where?: Record<string, unknown>; orderBy?: Record<string, "asc" | "desc">[]; take?: number; }): Promise<unknown[]>; } export function prismaFetch(db: { ticket: TicketDelegate }) { return async ({ query, auth }: { query: QuerySpec; auth: unknown }) => { const where: Record<string, unknown> = { // Row-level tenancy comes from the END USER's auth, not from the query. orgId: String(auth), }; for (const filter of query.filters) { if (filter.op === "eq") where[filter.field] = filter.value; if (filter.op === "in") where[filter.field] = { in: filter.value }; } return await db.ticket.findMany({ where, orderBy: query.sort.map((sort) => ({ [sort.field]: sort.dir })), take: query.limit ?? 200, }); }; }

Supabase

src/supabase.ts
// Supabase recipe — supabase-js query builder with the user's JWT so // Postgres RLS applies to every row. Same rule as always: map what's cheap // (eq/in, order, limit), let the engine do the rest. import type { QuerySpec } from "@ticora/core"; /** The slice of supabase-js the recipe touches — substitute your client. */ export interface SupabaseQuery { eq(column: string, value: unknown): SupabaseQuery; in(column: string, values: readonly unknown[]): SupabaseQuery; order(column: string, options: { ascending: boolean }): SupabaseQuery; limit(count: number): SupabaseQuery; then( onFulfilled: (result: { data: unknown[] | null; error: { message: string } | null }) => void, ): void; } export interface SupabaseLike { from(table: string): { select(columns: string): SupabaseQuery }; } export function supabaseFetch(client: SupabaseLike) { return async ({ query }: { query: QuerySpec; auth: unknown }) => { // Create the client per-request with the end user's JWT // (createClient(url, anonKey, { global: { headers: { Authorization } } })) // so RLS scopes rows — `auth` never needs manual WHERE clauses here. let builder = client .from("tickets") .select("id,subject,priority,assignee,age_hours,opened"); for (const filter of query.filters) { if (filter.op === "eq") builder = builder.eq(filter.field, filter.value); if (filter.op === "in") builder = builder.in(filter.field, filter.value); } for (const sort of query.sort) { builder = builder.order(sort.field, { ascending: sort.dir === "asc" }); } builder = builder.limit(query.limit ?? 200); const { data, error } = await new Promise<{ data: unknown[] | null; error: { message: string } | null; }>((resolve) => builder.then(resolve)); if (error) throw new Error(error.message); return data ?? []; }; }

Which one am I?

Your data lives…RecipeWhere the fetch runs
behind an internal REST APIRESTbrowser (with user JWT) or server
behind GraphQLGraphQLbrowser or server
in Postgres via PrismaPrismaserver only (route handler the browser calls)
in SupabaseSupabasebrowser — per-request client with the user’s JWT, RLS scopes rows
Last updated on