Record sections
Load the comments, files and tags of one record page in parallel, each settled, hidden when the caller can't read it, and kept current over one realtime subscription.
A record page, such as a customer, shows parts that different blocks own: a
comment thread, its files, its tags. Each block declares those parts as
sections on its manifest, and better-supabase/records loads them for one
SubjectRef. Every section settles on its own, so a failed or forbidden
section never takes the page down:
kind | Means |
|---|---|
ok | data holds the section's rows |
error | error holds a plain DbError; show it with a Retry |
hidden | The caller can't read the section, and no query ran for it |
| Block | Section | Rows | Writes | Optimistic reducer |
|---|---|---|---|---|
comments | comments | the thread | add, remove | commentsOptimistic |
attachments | attachments | the files, newest first | remove | attachmentsOptimistic |
tags | tags | the record's tags | apply, remove | tagsOptimistic |
share-links | shareLinks | the record's share links | create, revoke | none |
relations | relations | links to and from the record | link, unlink | none |
versions | versions | the versions, newest first | snapshot, restore | none |
sla | sla | the record's clocks | start | none |
ledger | ledger | the record's accounts | none | none |
assets | assets | the assets the record holds | none | none |
guests | guests | the guests who reach the record | grant, revoke | none |
passes | passes | the passes issued for the record | issue, revoke | none |
billable-time | billableTime | the record's unbilled time | setRate, invoice | none |
contracts | contracts | the contracts with the record | none | none |
dispatch | dispatch | the work orders opened for the record | create, cancel | none |
shipments | shipments | the shipments sent for the record | create, cancel | none |
A write takes the record's id and the write's input, such as
{ id, input: { kind: "relates", target } } for relations.link.
Generated features
With a block that adds sections installed, gen writes one
feature per subject in features/<subject>/ next to the main module, with a
section for each installed block that serves the subject. A block serves the
subject types its options.subjects names, or every tenant subject when it
names none; versions serves the subjects that set versions. Set
generateFeatures: false in the config to write none. gen writes no UI:
the cards come from the UI recipes.
| File | Written when | Exports |
|---|---|---|
<subject>-feature.ts | always | <subject>Feature, from defineRecordFeature |
<subject>-server.ts | Next.js is installed | <subject>Record, from recordServer(bs, feature) |
<subject>-actions.ts | Next.js is installed | one Server Action per write, such as addCustomerComments |
<subject>-provider.ts | Next.js, Vite or Expo is used | <Subject>RecordProvider and use<Subject>Record |
<subject>-queries.ts | @tanstack/react-query is found | <subject>Queries(clients), from recordQueries |
features/index.ts re-exports every feature. The files carry the
@generated header; change the config and run gen again instead of
editing them.
In a Server Component
<subject>Record.load(id, options?) loads the sections and the caller's
can flags as plain data (Temporal values become ISO strings), so the view
passes straight to a Client Component. It reads the request, so call it
outside "use cache":
import { CustomerRecordProvider } from "@/lib/supabase/features/customer/customer-provider";
import { customerRecord } from "@/lib/supabase/features/customer/customer-server";
export default async function CustomerPage({
params,
}: PageProps<"/customers/[id]">) {
const { id } = await params;
const view = await customerRecord.load(id, {
sections: ["comments", "versions"],
options: { versions: { pageSize: 20 } },
});
return (
<CustomerRecordProvider id={id} initial={view} topic={`customer:${id}`}>
<CommentsCard />
</CustomerRecordProvider>
);
}Each Server Action builds the block's client over the caller's Db and its
supabase.storage, runs the write, and drops the section's
cache tags. RLS and the block's SQL
decide what the caller may write; check can to hide a button.
In a Client Component
use<Subject>Record() returns { state, actions, meta }: the settled
sections and can flags, refresh(names?), and write(section, write, input), which runs the Server Action and loads that section again:
const { state, actions } = useCustomerRecord();
const comments = state.sections.comments;
await actions.write("comments", "add", { body });Without Next.js the provider runs the writes on the client, over the
supabase-js client of BetterSupabaseProvider.
With TanStack Query
<subject>Queries(clients) returns query options per section, mutation
options per write, and can(). Pass the block clients keyed like the
feature's blocks; invalidateOnRecordEvents(queryClient, betterSupabase, feature) makes a section stale when one of its events arrives:
const customer = customerQueries(blocks(supabase), {
access: blocks(supabase).access,
});
const comments = useQuery(customer.sections.comments(id));
const add = useMutation(customer.writes.comments.add(id));
add.mutate({ body: "Shipped" });Vite and Expo
A Vite app runs gen like any other, so it gets the feature, the client
provider and, with TanStack Query installed, the queries file. An Expo app
usually doesn't run gen itself: in a monorepo, import the features from
the package that does, or run gen once with output inside the Expo app.
defineRecordFeature, createRecordProvider and recordQueries are plain
functions, so a hand-written feature works the same way.
Load a record
loadSections(source, subject, names, options?) takes the manifests and the
clients built from them, keyed alike, such as blockSet.blocks and the
blocks you built with blockSet.create(...). It runs the sections in
parallel and never throws:
import { loadSections, sectionCan } from "better-supabase/records";
import { blockSet, blocks } from "@/lib/blocks";
import { permissions } from "@/lib/supabase/blocks.generated";
export async function customerSections(supabase: Client, id: string) {
const built = blocks(supabase);
const set = await built.access.permissions();
return loadSections(
{ blocks: blockSet.blocks, clients: built },
{ type: "customer", id },
["comments", "attachments", "tags"],
{
can: sectionCan(set.ok ? set.data : null, permissions),
options: { comments: { limit: 20 } },
},
);
}sectionCan(permissionSet, permissions) takes the caller's
PermissionSet and checks the key each section's module
names in the permissions constant of blocks.generated.ts, so a renamed
permission still matches. With no set (null), every section that needs a
key is hidden. Without can, every section loads and RLS decides
what it returns. Section names are unique across the app: when two blocks
declare the same name, the first block in the set wins.
countSection(source, name, subjects) returns the row count of one section
for many records, keyed type:id, for a badge on each row of a list. It
fails with unsupported for a section without count.
With "use cache"
A cached function stores what it returns, so a settled error would stay
cached. Call loadSections outside "use cache", or throw on error inside
it so the failure isn't stored, and tag the cached read with
sectionTag so a write drops it.
Header and badges
subjects.summary(subject, sections, { seenAt }) calls record_summary and
returns the record's title and path with a count and an unread flag for each
section, in one round trip. Pass seenAt, when the caller last opened the
record, to get unread state; without it every section counts as read. It
counts comments and attachments; it returns undefined when the caller
can't read the record.
const summary = await subjects
.summary({ type: "customer", id }, ["comments", "attachments"], {
seenAt: lastVisit,
})
.orThrow();
summary?.counts.comments; // 12
summary?.unread.attachments; // truerecord_summary runs as the caller, so the counts are the rows the caller
can read.
Keep it current
createRecordController loads the sections, and the summary when you pass
the subjects client, then listens on one private topic. A message whose event
a section lists in its events loads that section again; any other message
loads every section. Like the other
block controllers, it imports no UI code.
import { createRecordController } from "better-supabase/records";
import { topicPort } from "better-supabase/realtime";
const record = createRecordController({
source: { blocks: blockSet.blocks, clients: blocks(supabase) },
auth: bs.auth,
ports: { topic: topicPort(supabase) },
subject: { type: "customer", id },
sections: ["comments", "attachments"],
topic: `customer:${id}`,
summary: subjects,
});
record.subscribe(() => {
const { sections, summary } = record.getSnapshot();
});
await record.actions.refresh(["comments"]);Optimistic writes
Each section exports a reducer for React's useOptimistic, so a pending
write shows before the server answers:
import { attachmentsOptimistic } from "better-supabase/blocks/attachments";
const [files, applyPending] = useOptimistic(rows, attachmentsOptimistic);
startTransition(async () => {
applyPending({ type: "remove", id });
await removeAttachment({ id, customerId });
});On the server, bs.sectionAction
runs the write and drops the cached section.
Write a section
defineSection describes a section of your own block. It is an extension
interface (apiVersion: 1); put it on the manifest's sections:
import { defineSection } from "better-supabase/records";
export const workNotesSection = defineSection({
permissions: { read: "read", write: "create" },
load: (client: WorkNotes, subject) => client.list(subject),
count: (client: WorkNotes, subjects) => client.countFor(subjects),
events: ["work-note.*"],
});
export const workNotesBlock = defineBlock({
name: "work-notes",
sql: ["work-notes"],
sections: { "work-notes": workNotesSection },
// ...
});| Field | What it is |
|---|---|
permissions | The module actions the section reads and writes with, keys of permissions.<module> |
module | The SQL module those actions belong to; default the block's first one |
load | The rows for one subject, as an AsyncResult |
count | Optional: rows per subject, keyed type:id |
events | The block events that make the section stale; * ends a prefix |
optimistic | Optional: a reducer for useOptimistic |
testSection in better-supabase/testing checks a section against this
contract (Conformance).
Last updated on
Subjects
Titles and links for any record in config.subjects, resolved in one batched call, with the SubjectLink and SubjectChip recipes.
Record lists and My work
Show section columns on a list page, keep its state in the URL, merge a record's sections into one timeline, and list what waits on the caller across blocks.