# Operational data

> Store data your users create at runtime, such as form submissions, orders, and comments, and read it back safely.

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

> Needs the Postgres storage engine.

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:

`graft.config.ts`:

```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`:

```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](/docs/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](/docs/observability).

## 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`:

```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 }));
  },
});
```

> **Queries are open by default.**
> Unlike mutations, query functions accept anonymous callers unless you add an
> `access` rule. Any query that returns personal data, like this one, needs one.
> [Auth](/docs/auth) explains scopes.

`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:

```ts
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](/docs/search): words,
`"quoted phrases"`, `or`, and `-exclusions`.

```ts
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.

```ts
const updated = await updateRecord(ctx, orders, id, { status: "shipped" });
```

To remove a record, use `deleteRecord`:

```ts
const removed = await deleteRecord(ctx, submissions, id);
```

> **Deletes cannot be undone.**
> `deleteRecord` removes the row for good. Git cannot restore it, because Git
> never had it. Mark any function that calls it `destructive: true`, so it waits
> for a human [approval](/docs/audit-and-approvals) before it runs.

## What previews do with this data

[Preview branches](/docs/branching-previews) 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](/docs/functions-and-access) covers everything a function
  can declare.
- [Rate limits](/docs/rate-limits) protects a public form.
- [Audit & approvals](/docs/audit-and-approvals) puts a human check on deletes.
- [Migrations](/docs/migrations) updates existing records when the schema
  changes.
