# Reading and writing (/docs/schema/collections/crud)

---
title: Reading and writing
description: Every collection produces the same typed CRUD object and the same set of REST routes, so one vocabulary works across your whole schema.
kind: guide
package: questpie
---

How do you actually read and write a row, and how do you make the access rules
run? Same answer for every collection, because the vocabulary is generated, not
written per table.

## The call shape

Every method takes its own parameters first and a context second. The context
is where you say who is asking.

```ts
const { docs } = await app.collections.posts.find(
	{ where: { published: true }, with: { author: true }, limit: 20 },
	{ accessMode: "user", session },
);
```

Pass `{ accessMode: "user", session }` to turn the rules on. `{ locale }` reads
or writes a localized variant. `{ stage }` targets a workflow stage, and
`{ db: tx }` joins an open transaction.

### What an omitted context means

Every key resolves in the same order: what you passed, then the ambient
request context, then the default. So a call inside a hook or a route handler
already runs as the caller ran, session and all. You override only the key you
mean to change. Outside any request, with nothing ambient to inherit, the
default is `accessMode: "system"`, which skips your rules. That is the right
default for a job or a script.

<Callout type="info" title="A typo in the context is a compile error">
	`CRUDContext` carries no index signature, so an unknown key fails to compile
	rather than silently doing nothing.
</Callout>

## The methods

| Method                                | What it does                                                                  |
| ------------------------------------- | ----------------------------------------------------------------------------- |
| `find(opts, ctx)`                     | List rows. Returns the paginated envelope.                                    |
| `findOne(opts, ctx)`                  | First match, or `null`.                                                       |
| `count(opts, ctx)`                    | Count matches. Takes `where` and `includeDeleted`.                            |
| `create(input, ctx)`                  | Insert one row, nested relation writes included.                              |
| `updateById({ id, data }, ctx)`       | Update one row.                                                               |
| `updateMany({ where, data }, ctx)`    | Bulk update by filter. Returns the rows it wrote.                             |
| `updateBatch({ updates }, ctx)`       | Per-row patches, `[{ id, data }]`, in one transaction.                        |
| `deleteById({ id }, ctx)`             | Delete one row. Returns `{ success, data }`.                                  |
| `deleteMany({ where }, ctx)`          | Bulk delete. Returns `{ success, count }`.                                    |
| `restoreById({ id }, ctx)`            | Undo a soft delete.                                                           |
| `purgeById({ id }, ctx)`              | Remove a soft-deleted row for good.                                           |
| `findVersions(opts, ctx)`             | History. Empty array when versioning is off.                                  |
| `revertToVersion(opts, ctx)`          | Restore a past state.                                                         |
| `transitionStage({ id, stage }, ctx)` | Move a workflow stage. Throws when workflow is off.                           |
| `lockMany({ ids }, ctx)`              | Lock rows inside an open transaction. Server only.                            |
| `upload(file, ctx)`                   | Store a file. Only on [upload collections](/docs/schema/collections/uploads). |

`update` and `delete` are aliases of `updateMany` and `deleteMany`. Do not reach
for them. The same two names mean by-id on the client and bulk here, so one
letter of difference decides whether you touch one row or every matching row.
Write `updateMany` and `deleteMany` and the reader of your code can tell.

## What find takes

`where`, `with` for relation hydration, `columns`, `orderBy`, `limit`,
`offset`, `extras`, `search` (a case-insensitive `ILIKE` against `_title`),
`locale`, `localeFallback`, `includeDeleted`, `stage`, and `groupBy`. Grouping
changes the envelope: `limit` and `offset` then page the groups, and the result
carries `groups` and `totalGroups`. The query language itself, operators,
relation filters and aggregations, lives in
[Relations](/docs/schema/relations).

## Version history obeys the read rule

`findVersions({ id })` takes `limit` and `offset` and nothing else. It returns
snapshots oldest first, or an empty array when versioning is off.

The read rule runs against two things. First the current row. A row you may not
read gives you `403` instead of a history. Then every snapshot. The rule
compiles into the query that reads `<name>_versions`. A row that changed tenant
shows the new tenant only the snapshots carrying its own id.

That second check is SQL, and it runs before `limit` and `offset`. Paging the
history cannot hand you a snapshot the rule would have rejected.

Scalar fields on the collection compile. Localized fields compile too, against
`<name>_i18n_versions`. Everything else throws: relation filters, `RAW`,
virtual fields, and any key the versions table does not have. The query cannot
ask a relation what it held a year ago, so `findVersions()` refuses rather than
answer from the present.

## Bulk writes claim their rows

`updateMany` and `deleteMany` lock the matched rows and re-evaluate `where`
inside the transaction. A row is written only if it still matches at write time.
`updateMany` returns the rows that won. An empty array means nothing matched at
write time, whether it never existed or somebody else got there first.

```ts
const claimed = await collections.seats.updateMany(
	{ where: { id, holder: { isNull: true } }, data: { holder: userId } },
	{ accessMode: "system" },
);
if (claimed.length === 0) {
	// somebody else took it, and you can see that
}
```

## Locks across collections

An invariant can span several collections. Take the lock on the aggregate root
with `lockMany` inside `withTransaction`. Do not leak raw `SELECT … FOR UPDATE`
into your services.

```ts
await withTransaction(db, async (tx) => {
	const context = { accessMode: "system" as const, db: tx };
	const locked = await collections.companies.lockMany(
		{ ids: [companyId] },
		context,
	);
	if (locked.length !== 1) throw ApiError.conflict("Company is unavailable");
	// re-read and write every participant on the same tx
});
```

It takes at most 100 ids, deduplicates them, and locks in id order. It applies
read access in the locking query, and returns only the ids you may see. A
missing id and one you may not read look the same on purpose. The context must
carry the active transaction, and it rejects the call otherwise.

`lockMany` runs no hooks, returns no rows, writes nothing, and emits no
realtime event. It is not reachable over REST, the client SDK, MCP or OpenAPI.
For a conditional write on a single collection, claim-checked `updateMany` is
the smaller tool.

## The REST routes

Real file-convention routes the core module ships, mounted under the base path
your handler uses. The starter templates mount at `/api`.

| Method   | Path                              | Runs                                           |
| -------- | --------------------------------- | ---------------------------------------------- |
| `GET`    | `/:collection`                    | `find`                                         |
| `POST`   | `/:collection`                    | `create`                                       |
| `PATCH`  | `/:collection`                    | `updateMany`                                   |
| `GET`    | `/:collection/count`              | `count`                                        |
| `POST`   | `/:collection/delete-many`        | `deleteMany`                                   |
| `POST`   | `/:collection/update-batch`       | `updateBatch`                                  |
| `GET`    | `/:collection/:id`                | `findOne`                                      |
| `PATCH`  | `/:collection/:id`                | `updateById`                                   |
| `DELETE` | `/:collection/:id`                | `deleteById`                                   |
| `POST`   | `/:collection/:id/restore`        | `restoreById`                                  |
| `POST`   | `/:collection/:id/purge`          | `purgeById`                                    |
| `GET`    | `/:collection/:id/versions`       | `findVersions`                                 |
| `POST`   | `/:collection/:id/revert`         | `revertToVersion`                              |
| `POST`   | `/:collection/:id/transition`     | `transitionStage`                              |
| `GET`    | `/:collection/:id/audit`          | audit entries, when an audit collection exists |
| `POST`   | `/:collection/upload`             | `upload`                                       |
| `GET`    | `/:collection/files/*key`         | serve file bytes                               |
| `GET`    | `/:collection/schema` and `/meta` | introspection                                  |

The same operations reach you through the
[typed client](/docs/client/sdk) and appear in the OpenAPI document.

## Next

**[Access control](/docs/schema/access-control)** is what `accessMode: "user"`
turns on, including rules that return a `where` and filter instead of denying.
