Caching
How reads know which tables they touched, and how writes invalidate exactly those reads.
better-supabase never patches cached rows. Every read records the tables it touched, every write reports the tables it changed, and caches refetch the reads that overlap. It's simpler than normalized caching and stays correct with RLS, includes, relation filters and cascading deletes, because the database does the work again instead of the client guessing.
Reads: touched tables
A read touches its table, every table it includes (nested includes too,
and _count includes), and every table named in a relation filter:
db.customers.findMany({
where: { notes: { some: { kind: "call" } } },
include: { organization: true, _count: { locations: true } },
});
// touches customers, notes, organizations, locationsbetterSupabase.tablesOf(spec) returns that list for a query spec, and
touchedTables(op) returns it for a built operation.
Writes: invalidation targets
A mutation invalidates its own table. A delete also invalidates tables whose
rows change because of it: foreign keys with on delete cascade, set null
or set default, followed through further cascades. gen records those
actions from the database, so there's nothing to configure.
invalidationTargets(betterSupabase.meta, "customers");
// ['customers', 'contacts', 'locations', 'notes', 'customerTags', ...]Database functions don't say what they change. Declare it once:
export const betterSupabase = defineSupabase(schema).defineRpc(
"archive_customer",
{
invalidates: ["customers", "notes"],
},
);db.$rpc('archive_customer', ...) then invalidates those tables like any
mutation.
Cache adapters
betterSupabase.cache(adapter) subscribes a CacheAdapter
to every mutation and declared RPC. Each call gets a CacheTarget with the
table, the changed row ids, and tables, the full list to invalidate.
| Adapter | What it invalidates |
|---|---|
queryCache(queryClient) from better-supabase/query | TanStack queries whose meta.bsTables overlaps tables |
nextCache() from better-supabase/next | tagFor(table) for each table and tagFor(table, id) per row, via revalidateTag |
Your own adapter implements one method, and testCacheAdapter from
better-supabase/testing checks it against the same cases.
TanStack Query
Query options from createQueries carry meta: { bsTables }. Invalidation
is a predicate over that meta, so a write to notes refetches a
customers.findMany that included notes, and nothing else. Keys stay
['bs', table, method, ...args] for devtools and manual invalidation. See
TanStack Query.
Next.js
In a cached server component, tag the data with what it touched:
"use cache";
const spec = betterSupabase.spec.customers.findMany({
include: { notes: true },
});
bs.cacheTags(spec); // tags customers and notes
const customers = await db.$run(spec).orThrow();Server actions and route handlers that write through db revalidate those
tags automatically (cacheTags: false turns it off).
bs.cacheTags also takes an array of specs or a
read set, and tags the union of the tables
they read.
Query specs
A QuerySpec is a read described as plain JSON:
{ v: 1, table, method, args }. betterSupabase.spec.<table>.<method>(...) builds one
with full types, db.$run(spec) runs it, and InferResult<typeof spec> is
its result type. Because it serializes, the same spec can be built on the
server, sent to the client, used as a query key and passed to
useLiveQuery.
const spec = betterSupabase.spec.customers.findMany({
select: ["id", "name"],
limit: 20,
});
type Rows = InferResult<typeof spec>; // { id: string; name: string }[]
await queryClient.prefetchQuery(q.$spec(spec));Live updates
Live queries reuse the same model: a trigger broadcasts "table changed" (no row data) and the client invalidates the queries that touched that table, which then refetch through RLS.
Last updated on