Data access (ctx.services)
Read and write data from a plugin through the managed, secure-by-default service layer — queries, bulk operations, and system elevation.
ctx.services is the managed data path for plugins. Prefer it over the raw
ctx.db escape hatch: it enforces validation, hooks, events, and RBAC, and it stays
portable across database dialects. It is @public and semver-protected (D56).
init(ctx) {
const posts = await ctx.services.collections.listEntries(
ctx.self.collections.posts, // resolve your own slugs via ctx.self
{ where: { status: "published" }, sort: { field: "createdAt", direction: "desc" } },
{ as: "system" }
);
}Secure by default (D35)
Every access method takes a trailing ServiceOpts ({ as, user }) that decides whose
permissions apply:
opts | Behaviour |
|---|---|
{ as: "user", user } | Runs as that caller — RBAC enforced. In a plugin route the caller's API-key scope is carried automatically, so a key is judged on its own grants rather than its owner's; pass authenticatedScope explicitly only to narrow it. |
{ as: "system" } | Elevates — bypasses the access check for trusted, non-user work (jobs, derived data, lookups). Visible and greppable by design. |
{} / omitted | No user → defaults to system (jobs, CLI, migrations). |
Validation, hooks, and events always run, even under { as: "system" } — only the
access check is skipped. So a system write still fires afterCreate hooks and emits
events.
v1 limitation: under
{ as: "user" }, code-definedaccessrules that readctx.user.rolesee it empty (RBAC is enforced by a DB lookup onuser.id). Pass{ as: "system" }, or rely on DB-level RBAC, when you need role-aware access.
Pick the elevation deliberately. A public route that lists published content should use
{ as: "system" } (the data is public-derived); a route that returns a user's own
records should use { as: "user", user: ctx.user ?? undefined }.
Reading and writing in a locale
On a localized site, name the language beside the elevation. locale decides which
translation a read answers with and which one a write stores into; absent, it is the
site's default, which is what every call has always meant.
// A French visitor's page reads French content.
const frenchPage = await ctx.services.collections.findEntryById("pages", id, {
as: "system",
locale: "fr",
});
// A translation is written the same way: only the localized fields move.
await ctx.services.collections.updateEntry(
"pages",
id,
{ title: "Merci" },
{ as: "system", locale: "fr" }
);A missing translation is answered by the site's fallback chain, which is right when the
page has to show something. Pass fallbackLocale: false when the question is whether
the translation exists — a sitemap deciding whether to list a language, say — and the
default's value standing in would mislead you.
What an unconfigured code means depends on the verb, and deliberately so. A read
resolves it to the default, the same way ?locale= does on the REST API, so a wrong
query string still shows a page. A write is refused with a 400, so a typo cannot
overwrite the default language's content. createMany refuses any locale at all, by
name: its bulk pipeline writes rows in one pass and cannot store a translation, and an
option it accepted but could not honour would file every row under the default language
and report success. Create the rows one at a time with createEntry instead.
locale names one language. The selectors the core understands elsewhere — *,
which moves every translation's lifecycle in one write, and all, which answers a read
with one value per language — are refused here with a 400, so a value forwarded from a
query string can never publish every translation of a document by accident. A plugin
that needs the every-locale sweep needs a surface that says so by name; there is none
yet. A route that forwards ?locale= and wants a wildcard to read as "no language" can
ask the core which spellings are selectors — isLocaleSelector from
@nextlyhq/plugin-sdk, @experimental until it ships in a release — rather than keep a
list of its own. The same spellings are reserved at configuration — along
with none, the wire's spelling of fallbackLocale: false — so a site cannot name a
language all or none. An empty locale (what ?locale= with nothing after it
forwards) reads as no language named, not as a wrong one.
deleteEntry refuses a locale too, by name: a delete removes the document and every
translation with it, and there is no per-language delete — so { locale: "fr" } would
read as "remove the French translation" and remove the English, the German and the row.
With that, the pair is spelled exactly as it is on the request context and on the wire, and a route can pass through what it was given: a wrong code shows the default on a read and is refused on a write, and a selector is refused on both.
Collection methods
ctx.services.collections exposes:
| Method | Purpose |
|---|---|
createEntry(slug, data, opts?) | Create one entry. Returns { message, item, warnings? }. |
listEntries(slug, query?, opts?) | List with filter/sort/pagination → PaginatedResult. |
findEntryById(slug, id, opts?) | Fetch one by id. |
updateEntry(slug, id, data, opts?) | Update one. Returns { message, item, warnings? }. |
deleteEntry(slug, id, opts?) | Delete one. Returns { message, item: { id }, warnings? }. |
count(slug, where?, opts?) | Count matching rows. |
createMany(slug, rows, opts?) | Bulk insert → BatchOperationResult. |
updateMany(slug, entries, opts?) | Bulk update, one { id, data } per row → BatchOperationResult. |
ctx.services also exposes users, media, and email services.
ctx.services.users may create and update accounts, but not decide who administers the
site. It refuses, with NextlyError code FORBIDDEN, to create an install's first
account (core makes that one super-admin), to give a role that reaches super-admin
(directly or by inheritance) on create or update, and to change the password, email,
activation, email verification or roles of an account that reaches super-admin, or to
delete one. An address it marks verified is recorded as verified by plugin.
Querying (D56)
listEntries takes a QueryOptions:
const result = await ctx.services.collections.listEntries(
slug,
{
where: { status: "published", authorId: someId }, // filter (key/value)
sort: { field: "createdAt", direction: "desc" }, // single-field sort
pagination: { limit: 20, page: 1 }, // page through results
depth: 1, // 0–5: how deep to populate relations (0 = ids only)
select: { title: true, slug: true }, // projection: return only these fields
},
{ as: "system" }
);
result.data; // the rows for this page
result.pagination; // { total, limit, offset, hasMore }Use depth: 0 when you only need scalar fields (e.g. building a sitemap) — it avoids
populating relations and is cheaper.
Bulk operations
createMany and updateMany return a BatchOperationResult, so a partial failure
tells you exactly which rows failed and leaves the rest committed:
const res = await ctx.services.collections.createMany(slug, rows, { as: "system" });
// { successful, failed, ids: string[], errors: [{ index, error }] }updateMany takes one { id, data } per row, so a single call can apply a
different patch to each row. Applying the same patch to many rows is the same
call with the patch repeated:
const res = await ctx.services.collections.updateMany(
slug,
[
{ id: "post-1", data: { status: "published" } },
{ id: "post-2", data: { status: "archived", pinned: false } },
],
{ as: "system" }
);errors[].index indexes the array you passed, so the row a failure is about is
entries[index].id. The result shape is shared with createMany, which has no
caller-supplied ids to key its failures by.
To update every row matching a filter, list them first and map the ids. There is no by-filter batch update, deliberately: a filter that matches more than the author meant is the failure this surface has no way to take back.
listEntries returns one page, so page through it. The loop below re-reads page 1
each time on purpose: every updated row leaves the draft filter, so the rows that
still match move up into it.
let updated = 0;
for (;;) {
const page = await ctx.services.collections.listEntries(
slug,
{ where: { status: "draft" }, pagination: { limit: 100 }, depth: 0 },
{ as: "system" }
);
if (page.data.length === 0) break;
const res = await ctx.services.collections.updateMany(
slug,
page.data.map((row: { id: string }) => ({
id: row.id,
data: { status: "published" },
})),
{ as: "system" }
);
updated += res.successful;
// Nothing moved: the rest are failing rather than draining, so stop rather
// than read the same page forever.
if (res.successful === 0) break;
}When the patch does NOT change what the filter matches, page with
pagination: { page: n } instead and stop when pagination.hasMore is false, or the
loop above would re-read the same rows.
Neither takes a locale: the bulk pipeline writes in one pass and cannot store a
translation, so both refuse one by name rather than filing every row under the default
language. Write the rows one at a time with createEntry or updateEntry when a
language matters.
Writes return an envelope
createEntry, updateEntry and deleteEntry resolve to
{ message, item, warnings? } — the same shape the Direct API and the REST API
return, so the same failure is equally visible however the write was made.
const { item, warnings } = await ctx.services.collections.createEntry(
slug,
data,
{ as: "system" }
);
if (warnings) {
// The row IS saved. A post-commit hook (`afterCreate` and friends) runs once
// the write has committed, so a handler failing there cannot un-save it —
// report it, don't retry the write.
ctx.logger.warn("side effects failed", { id: item.id, warnings });
}deleteEntry reports item as { id }, since there is no row left to return.
When to drop to ctx.db
ctx.db (raw Drizzle) is the @experimental escape hatch — unmanaged: it bypasses
validation, hooks, RBAC, and events, and you own dialect portability. Reach for it only
for things the services layer doesn't cover yet (aggregations beyond count). If you
find yourself needing it often, that's a signal worth raising — the goal (D56) is that
most plugins never touch it.
It is Drizzle's fluent API for the configured dialect: select, insert,
update, delete, and transaction(async tx => { ... }), which commits the
work as one unit and rolls it back if the work throws, on every dialect and
with or without rawSql. Inside it, write through tx. Without rawSql, tx
has no nested transaction; with it, tx is the live handle and its
transaction nests as a savepoint. Another service or ctx.settings called
there runs on its own connection on PostgreSQL and MySQL, outside the
transaction, and commits on its own.
On SQLite the transaction holds the database's only connection until it ends. A
service or ctx.settings called inside joins it as a savepoint, and its rows
roll back with it. What a service does after its own write waits until your
transaction commits: collection hooks (afterCreate, afterUpdate,
afterDelete), field afterChange hooks, events such as user.created,
media.uploaded or plugin.settings.changed, cache revalidation and webhook
delivery run once, before ctx.db.transaction resolves, and never run if it
rolls back. Inside such a transaction an after-hook's error is logged rather
than failing the call, and a value a field afterChange hook or a Single's
afterUpdate hook returns does not reshape the service's response. A media
delete (ctx.services.media.delete or bulkDelete) called inside it is refused
with 409 CONFLICT, so stored files are never removed for a delete that rolls
back: delete media after the transaction. A focal-point change made inside it
keeps the superseded image variants rather than deleting them.
Statements from other requests wait for the transaction or run inside it. Core's
security writes — sign-out, the refresh-token theft response, failed-attempt and
lockout counts, role assignment and removal, API-key revocation and audit
entries — queue behind it instead, so its rollback cannot undo them. Keep it
short, and never await network I/O such as ctx.fetch inside it.
Nested transactions on SQLite run one at a time, in the order they are started. A nested transaction must never wait for one started after it in the same transaction, for example through a memoised or cached promise that a later sibling created. Neither settles, and the connection stays inside your open transaction: every later transaction waits, and other requests' writes land in it without ever committing, so a restart loses them. Start shared work before the transactions that wait on it, or outside the transaction.
There is no relational query namespace: the one that used to exist named a
few core tables, so it could never answer about a plugin's own tables.
Without capabilities.db.rawSql the handle has no execute or run; with it,
ctx.db is the live Drizzle instance. The capability is a declaration a
reviewer can read, not an enforced boundary: plugins run as trusted code.
See also: Permissions · HTTP routes.
Lifecycle, dependencies & order
How and when a plugin runs — the setup/init/destroy lifecycle, load order, declaring dependencies on other plugins, version compatibility, and enabling/disabling.
Permissions
Declare custom permissions from a plugin, gate routes and admin UI, and check permissions on the client — without ever granting access yourself.