QUESTPIE
SchemaCollections

Reading and writing

Every collection produces the same typed CRUD object and the same set of REST routes, so one vocabulary works across your whole schema.

View markdown

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.

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.

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.

The methods

MethodWhat 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.

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.

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.

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.

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.

MethodPathRuns
GET/:collectionfind
POST/:collectioncreate
PATCH/:collectionupdateMany
GET/:collection/countcount
POST/:collection/delete-manydeleteMany
POST/:collection/update-batchupdateBatch
GET/:collection/:idfindOne
PATCH/:collection/:idupdateById
DELETE/:collection/:iddeleteById
POST/:collection/:id/restorerestoreById
POST/:collection/:id/purgepurgeById
GET/:collection/:id/versionsfindVersions
POST/:collection/:id/revertrevertToVersion
POST/:collection/:id/transitiontransitionStage
GET/:collection/:id/auditaudit entries, when an audit collection exists
POST/:collection/uploadupload
GET/:collection/files/*keyserve file bytes
GET/:collection/schema and /metaintrospection

The same operations reach you through the typed client and appear in the OpenAPI document.

Next

Access control is what accessMode: "user" turns on, including rules that return a where and filter instead of denying.

On this page