Operational data

Save what your visitors submit, and read it back with the same types as your content.

Runtime data5 min read

needs Postgres

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

ContentOperational data
ExampleA pricing pageA contact-form submission
Stored asA file in content/A row in Postgres
Written byYou and agents, by editing filesYour users, through typed functions
Read byYour pages, search, and agentsTyped functions only
Declared asA normal collectionA 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:

graft.config.ts
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:

graft.config.ts
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.fields reuses the collection's fields as the function's input, so the form and the stored row can never disagree.
  • public: true lets visitors who are not signed in call it. Functions that write data refuse anonymous callers unless you say otherwise.
  • rateLimit allows 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:

graft.config.ts
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