Operational data
Save what your visitors submit, and read it back with the same types as your content.
Your site has a contact form. When a visitor submits it, the message has to be saved somewhere. It cannot be a Markdown file in Git: nobody wrote it ahead of time, and there is no file to rebuild it from. Graft calls data like this, created by your users while the site is running, operational data.
Operational data lives in Postgres. Your app never writes it directly. It goes through a typed function you define, which checks the input against a schema, records who sent it, and applies your access rules and rate limits.
By the end of this page you will have a contact form that saves submissions, a query that lists them for your team, and a picture of what changes when you add moderation, edits, and deletes.
How operational data differs from content
| Content | Operational data | |
|---|---|---|
| Example | A pricing page | A contact-form submission |
| Stored as | A file in content/ | A row in Postgres |
| Written by | You and agents, by editing files | Your users, through typed functions |
| Read by | Your pages, search, and agents | Typed functions only |
| Declared as | A normal collection | A collection with authority: "db-authoritative" |
Graft keeps the two apart. A Markdown file in an operational collection's folder
fails the compile, and an agent cannot write operational data with
write_content. Both stop with AUTHORITY_MISMATCH, which tells you which
collection is involved.
Declare a collection
Declare it like any other collection, plus one flag:
export const submissions = defineCollection({
name: "submissions",
authority: "db-authoritative",
description: "Contact-form submissions. Written by submitContact.",
fields: {
email: field.string({
maxLength: 320,
pattern: /^[^@\s]+@[^@\s]+\.[^@\s]+$/,
description: "Sender address.",
}),
message: field.text({ maxLength: 4000, optional: true }),
},
});Give every field a maximum length. Anyone on the internet can submit a public form, and an unbounded field lets them fill your database, however strict your rate limit is.
Save a submission from a function
A typed function receives the form's input, already checked against the
schema, and saves it with insertRecord:
export const submitContact = defineFunction({
name: "submitContact",
kind: "mutation",
public: true,
rateLimit: { limit: 5, windowSeconds: 60 },
description: "Stores a contact-form submission.",
returns: "{ id: string; receivedAt: string }",
input: submissions.fields,
handler: async (ctx) => {
const record = await insertRecord(ctx, submissions, ctx.input);
return { id: record.id, receivedAt: record.createdAt.toISOString() };
},
});Three lines in it are worth a closer look:
input: submissions.fieldsreuses the collection's fields as the function's input, so the form and the stored row can never disagree.public: truelets visitors who are not signed in call it. Functions that write data refuse anonymous callers unless you say otherwise.rateLimitallows each caller five submissions a minute. See Rate limits.
Your form posts to POST /api/fn/submitContact. Every saved row also records
who sent it and a correlation id, so you can trace it later in the
audit log.
List submissions for your team
There is no read SDK for operational data, on purpose: every read goes through
a function, so who may read is decided in the same place as who may write. The
defaults differ, though: a query is open to anyone until you give it an
access rule. Write a query function with one:
export const recentSubmissions = defineFunction({
name: "recentSubmissions",
kind: "query",
access: requireScopes("submissions:read"),
returns: "{ id: string; email: string }[]",
input: {},
handler: async (ctx) => {
const rows = await listRecords(ctx, submissions, { limit: 20 });
return rows.map((row) => ({ id: row.id, email: row.data.email }));
},
});listRecords returns the newest rows first. limit defaults to 50 and is
capped at 500, because it often comes straight from a caller's input.
Filter in the query, not afterwards
Suppose a page lists approved comments. Use match so the database does the
filtering:
const approved = await listRecords(ctx, comments, {
limit: 20,
match: { pageSlug: "pricing", approved: true },
});
This matters for correctness, not just speed. The limit applies before any filtering you do in JavaScript. If you fetched the 20 newest comments and then dropped the unapproved ones, anyone could hide every approved comment by posting 20 new unapproved ones.
Search submissions
searchRecords runs a full-text search over every text value in a record,
with the same syntax as content search: words,
"quoted phrases", or, and -exclusions.
const hits = await searchRecords(ctx, submissions, 'refund "order 41" -spam');
Like every read, it only runs inside a function. Graft has no open search endpoint for operational data.
Change or remove a record
To approve a comment or mark an order shipped, use updateRecord. It merges
your change into the record and checks the result against the full schema. A
change that would break the schema fails with SCHEMA_VALIDATION_FAILED and
writes nothing.
const updated = await updateRecord(ctx, orders, id, { status: "shipped" });
To remove a record, use deleteRecord:
const removed = await deleteRecord(ctx, submissions, id);
What previews do with this data
Preview branches copy your content, never your users' data. A preview shows the site's pages without exposing real submissions:
- An overlay branch starts with no operational rows at all.
- A Neon fork starts with submissions and approvals cleared. The audit log is kept, because it is history.
Approvals are cleared for a security reason. If a fork kept them, an approval used on the fork would still look unused on the original, so one approved delete could run once per fork.
Next steps
- Functions & access covers everything a function can declare.
- Rate limits protects a public form.
- Audit & approvals puts a human check on deletes.
- Migrations updates existing records when the schema changes.