# 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).

```ts
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-defined `access` rules that read
> `ctx.user.role` see it empty (RBAC is enforced by a DB lookup on `user.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.

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

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

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

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

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

```ts
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](https://nextlyhq.com/docs/plugins/permissions.md) · [HTTP routes](https://nextlyhq.com/docs/plugins/routes.md).
