You're reading docs for Nextly Alpha. APIs may change between releases.

Plugins

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:

optsBehaviour
{ 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.
{} / omittedNo 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.

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

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