# 0.0.2-alpha.67

18 Oct 2026 · Before you upgrade: 1 step · Run `nextly migrate`

- Adding Nextly to an existing Next.js app now installs everything it needs and finishes, on npm, pnpm, Yarn and Bun.
- New command: `nextly users:clear-plaintext-passwords`. Earlier releases stored some passwords as typed instead of hashing them. Such a value never let anyone sign in, but it is readable in the database and its backups.

Published: 18 Oct 2026
Changes: 90 changes
Notes: [On GitHub](https://github.com/nextlyhq/nextly/releases/tag/v0.0.2-alpha.67)

## Before you upgrade

<!-- release-notes -->

1. **Run `nextly migrate`, then install each configured plugin** Run these against your production database once, after deploying the new packages and before the new version serves traffic:
  
  ```bash
  npx nextly migrate                          # required for every project
  npx nextly plugins install <plugin-name>   # once for each plugin in your config
  ```
  
  **`nextly migrate` is required for every project, with or without plugins.** 0.0.2-alpha.67 adds two core tables, `nextly_version_pins` and `nextly_version_views`, and a production server never creates tables itself. Until `nextly migrate` has run, deleting documents fails with "relation … does not exist", while reading and saving still work.
  
  **If your project configures a plugin** (for example `@nextlyhq/plugin-form-builder` or `@nextlyhq/plugin-page-builder`), a production server refuses to start until the plugin has been installed on that database. It stops with `PLUGIN_NOT_INSTALLED` and names the command to run. Development only warns. A project made from the blog template before 0.0.2-alpha.67 needs:
  
  ```bash
  npx nextly plugins install @nextlyhq/plugin-form-builder
  ```
  
  Projects created with the 0.0.2-alpha.67 blog template run this install in their build script automatically. Installing leaves your existing content as it is, and running it again is harmless. See [Installing a plugin](https://nextlyhq.com/docs/guides/production-migrations#installing).


<!-- /release-notes -->

## What's changed

<!-- release-notes -->

Released 21 packages at `0.0.2-alpha.67` in lockstep. Every package below ships at this version.

### Patch Changes

- [#2075](https://github.com/nextlyhq/nextly/pull/2075) [`57bcb18`](https://github.com/nextlyhq/nextly/commit/57bcb188b3c33ac3c543aa491368a9c65f51166a) @nextly-agent: An update or delete by `where` under access rules now refuses a `where` on a field the caller may not read in the collection bulk service itself, before any row is found or written.

  The Direct API already refused it; the REST bulk routes, which reach the service without the Direct API in front, did not, so a caller allowed to write but not to read a field could guess its value and learn from which rows changed whether it matched. Callers with `overrideAccess: true` are unaffected.

- [#1874](https://github.com/nextlyhq/nextly/pull/1874) [`ce962c7`](https://github.com/nextlyhq/nextly/commit/ce962c7c0eda26659674261f544ea545e48afa2e) @mobeenabdullah: A class whose id was `__proto__` could not be renamed again after a refused save.

  The class manager records which rename of each class is the live one, and it kept that record on a plain object keyed by class id, where `__proto__` reads back an inherited object and a write to it stores nothing. The refused rename never released its pending name, so retrying the same name was taken as no change at all.
  
  The record is now a `Map`, and the pending names are read as the record's own
  entries in both the editor and the class manager panel, so a class behaves the
  same whatever id it carries.

- [#2032](https://github.com/nextlyhq/nextly/pull/2032) [`f6b972f`](https://github.com/nextlyhq/nextly/commit/f6b972f47252e9238a3f6dfef3fb1083a8aad8c1) @nextly-agent: A plugin cleared by `clearServices()` now reaches every service it was started with during its teardown, including those its context looks up through the container itself, such as `ctx.services.jobs` and `ctx.audit`.

  Those found the container empty, so a `destroy` that queued final work or wrote an audit entry failed, or its entry was dropped. A second `clearServices()` before the next registration, or one that arrives while that teardown is still running, no longer takes away the services it needs.

- [#2012](https://github.com/nextlyhq/nextly/pull/2012) [`357cd7f`](https://github.com/nextlyhq/nextly/commit/357cd7f02360dee17759cbb5fb0d44b2aa74453a) @nextly-agent: A plugin cleared by `clearServices()` is torn down when services register again, and its `destroy` can now still reach the services it was started with.

  `clearServices()` used to empty the container they lived in at once, so a teardown that opened a transaction, as `ctx.db.transaction` does through the adapter on SQLite, failed half-way and the new registration went on with the old plugin's cleanup unfinished. The plugin docs no longer say `destroy` runs on a hot reload in the author guide's example or the contributing checklist.

- [#2054](https://github.com/nextlyhq/nextly/pull/2054) [`4c13e6d`](https://github.com/nextlyhq/nextly/commit/4c13e6dbbfb1fc0f5720108231e2a20b16f10162) @aqib-rx: A plugin route that sets `csrf: true` now checks the origin of a POST, PUT, PATCH or DELETE that no session authenticates, before its handler runs.

  Before, such a request was not checked at all, so a guest-facing route, such as a cart that keeps its own cookie, accepted a cross-site form post. The request must name your site or an origin in `NEXTLY_ALLOWED_ORIGINS`, as a signed-in write must; one naming another origin, or none, is refused with `CSRF_FAILED`. It is not asked for a CSRF token. A session cookie that is expired or malformed authenticates nobody, so its caller is checked the same way, and is no longer asked for a token it could not hold.
  
  Only an `Authorization: Bearer` token, with the scheme in any case, now exempts
  a request no session authenticates from the check, whether or not the token is
  a valid key. A request a session authenticates is checked as the session's,
  whatever header it also carries. Any other
  scheme, such as the Basic, Digest, Negotiate or NTLM credentials a browser
  attaches on its own, no longer does.
  
  The plugin route documentation now lists which callers each `csrf` setting
  checks.
  
  **Compatibility:** a client that writes to a public route with `csrf: true`
  without a session or a `Bearer` token, such as a server job, must now send
  `Origin` with your site's origin or an allowed one, or it is refused. A route
  meant for webhooks or other server callers should leave `csrf` unset, which
  still checks no anonymous write. No first-party plugin route sets `csrf: true`.

- [#2214](https://github.com/nextlyhq/nextly/pull/2214) [`27b47fe`](https://github.com/nextlyhq/nextly/commit/27b47feeaaa8a1610a9794c46c33c1e2b1e7977c) @nextly-agent: Retention never removes a version a feature has pinned.

  A feature registers itself as a pin holder once, then points named slots at versions of a document (`versionPins.holder(name).pin(tx, { ref, slot, versionNo })`); moving a slot frees the version it left, and a version stays while any slot of any feature points at it. `registerVersionPinHolder`, `versionPins` and their types are exported from `nextly` as experimental, and the versions docs describe them. Pins are kept in a new core table, `nextly_version_pins`, which `nextly migrate` creates on an existing database, and they go with their document or Single when either is deleted. A pin takes the document's write lock, so it cannot slip past a save's retention pass.

- [#2163](https://github.com/nextlyhq/nextly/pull/2163) [`b7948bb`](https://github.com/nextlyhq/nextly/commit/b7948bb87d4b4958a6a5b662076dd87501f1e0fa) @faisal-rx: Whether a caller may filter, sort, group or search on a field now follows whether the field's `read` rule lets that caller read it.

  Before, any field with a `read` rule was refused for every caller, the ones the rule allows included.
  
  The rule is asked for the caller with no entry, since a query has none yet:
  - **A rule that allows the caller outright** (by role, permission or
    identity, such as `({ req }) => req.user?.role === "admin"`) now lets that
    caller filter, sort and group on the field, and a collection's search
    matches on it for them.
  - **A rule that refuses the caller** is refused as before, with a `400`
    naming the field as written: `FIELD_NOT_FILTERABLE`, `FIELD_NOT_SORTABLE`
    or `FIELD_NOT_GROUPABLE`. A collection's search leaves the field out.
  - **A rule that needs the entry to decide**, such as
    `({ req, data }) => data?.owner === req.user?.id`, cannot allow without one,
    so the field cannot be queried by anyone, its owner included, and search
    leaves it out. A filter or an ordering on it would reveal its value on the
    entries where it is hidden. To query by ownership, filter on the owner field
    itself.
  
  A widget data source still offers a group key only when no `read` rule
  guards the field, since one source serves every caller.

- [#2140](https://github.com/nextlyhq/nextly/pull/2140) [`1db672e`](https://github.com/nextlyhq/nextly/commit/1db672eafeeff8ea745d5eee893378ebeac7a001) @faisal-rx: A filter that is not well formed is now refused instead of being run without its bad part.

  Before, each form below was dropped on the way to the database, so the read matched more entries than the filter stated, and a filter that narrowed by owner or team could quietly return every entry. Through REST each answers `400` with a `VALIDATION_ERROR` naming the problem at `where.<path>`; through the Direct API, `find`, `count`, grouping, timelines and the bulk-by-query writes throw the same typed `NextlyError`.
  
  Newly refused, and what to write instead:
  - **An unknown operator** (`{ title: { equal: "a" } }`, or `startsWith`) —
    `FILTER_UNKNOWN_OPERATOR`. Use one of `equals`, `not_equals`,
    `greater_than`, `greater_than_equal`, `less_than`, `less_than_equal`,
    `like`, `contains`, `search`, `in`, `not_in`, `exists`, `near`, `within`.
  - **An unknown field**, or one with no column on the collection's table, such
    as a many-to-many relationship (`FILTER_UNKNOWN_FIELD`). Check the field's
    name; for a many-to-many relationship, read the related ids first and filter
    on `id`.
  - **A dot path that is not into a component** (`{ "author.name": … }`,
    `{ "meta.key": … }`) — `FILTER_UNFILTERABLE_PATH`. It used to compare the
    whole `author` or `meta` column with the value. Look the related entry's id
    up first and filter on the relationship field: `{ author: { equals: id } }`.
  - **`undefined`**, as a field's value or an operator's —
    `FILTER_UNDEFINED_VALUE`. Leave the condition out.
  - **An empty `in`** (`{ id: { in: [] } }`) — `FILTER_EMPTY_IN`. It matches no
    entry, so skip the query, or leave the condition out if you meant "any".
    An empty `not_in` still excludes nothing.
  - **An `and` or `or` that is not a list of filters**, or a list with a gap or
    a non-object in it — `FILTER_INVALID_LOGICAL` / `FILTER_NOT_AN_OBJECT`.
    Write `and: [{ … }, { … }]`.
  - **An operand of the wrong type** — `FILTER_INVALID_OPERAND`: an object or
    list given to `equals` (use `in` for several values), a value that is not a
    list given to `in` or `not_in` (write `["value"]`), `null` given to any
    operator or as a field's value (write `{ exists: false }`, or
    `{ exists: true }` for `not_equals: null`), a list given as a field's value
    (write `{ in: [...] }`), `exists` with anything but `true` or `false`, and a
    `near` or `within` that does not parse. A geo operand now parses only in its
    documented form: an unknown unit (it used to mean metres), parts beyond the
    last one the format names (they used to be ignored), or a number with
    trailing text (`5abc` used to read as 5) is refused.
  - **An empty condition** (`{ title: {} }`) — `FILTER_NO_OPERATOR`. Name an
    operator or leave the field out.
  - **A malformed language filter** (`_translated` without a `locale` string
    and a known `state`, or anywhere but the top level) —
    `FILTER_INVALID_OPERAND` / `FILTER_MISPLACED_KEY`.
  - **A component field or a geo operator inside `or`** — `FILTER_NOT_IN_OR`.
    Both are applied as a condition every entry must meet, so inside `or` they
    turned the alternative into a requirement. Move them to the top level or
    into `and`.
  - **A REST `where` that is not valid JSON, or not an object** —
    `FILTER_INVALID_JSON` / `FILTER_NOT_AN_OBJECT`. It used to read as no
    filter at all.
  - **A REST request carrying both `where=` JSON and `where[…]` parameters** —
    `FILTER_GIVEN_TWICE`. The bracket conditions used to be ignored. Send one
    form.
  - **A REST bracket key that is not bracketed segments**
    (`where[title]equals=a`), that names `__proto__`, whose list index is 50 or
    more (the most conditions a filter may have), that nests under a value
    another parameter gave, that mixes list indexes and named keys at one level,
    whose index has leading zeros, or that repeats a condition already given —
    `FILTER_INVALID_KEY`. A `__proto__` field key in a JSON `where` is refused
    as an unknown field. Fields named `constructor` or `prototype` filter as any
    other field does.
  - **A REST filter on the owner column** (`created_by`, `createdBy`, any
    spelling that resolves to it such as `CreatedBy`, or a dotted form) — `FIELD_NOT_FILTERABLE`. It used to be removed silently, so
    `or: [{ createdBy: … }]` matched every entry. The column is never returned,
    so it cannot choose rows either.
  
  Two meanings are corrected rather than refused: an empty `or` now matches no
  entry (it matched every entry), and an `or` holding an empty member such as
  `{}` now matches every entry, as a condition that is always true does.
  
  The REST bracket form also works again. `where[title][equals]=alpha` was read
  into a field named `title]equals`, which matched no column and was dropped, so
  every bracket filter returned all entries. A comma now splits values only for
  `in` and `not_in`, where a single value becomes a one-item list:
  `equals=Hello, world` compares with that text, and a `near` point reaches the
  geo filter intact.

- [#2059](https://github.com/nextlyhq/nextly/pull/2059) [`2a41096`](https://github.com/nextlyhq/nextly/commit/2a410960b019a4c5c73ae0980ed871579efe9ac1) @nextly-agent: Database adapters can tell whether the calling async flow is inside a transaction that has not finished yet, with `transactionOpenInFlow()` (`@experimental`).

  Each flow gets its own answer, never the connection's: on SQLite, another request's open transaction does not make it true. It is true inside transactions that core's services open through Drizzle on PostgreSQL and MySQL, as well as inside the adapter's own `transaction()`.

- [#2004](https://github.com/nextlyhq/nextly/pull/2004) [`755d99c`](https://github.com/nextlyhq/nextly/commit/755d99c195aacea8337ad45d6ba976719cd2d0d8) @nextly-agent: A fresh install no longer starts without the foreign keys a plugin's or the app's table declares to a collection's, Single's or field group's table.

  If one cannot be created, the first boot stops and names the table, the key and its target. The first boot also no longer prints a warning for each of those keys before creating them.

- [#2076](https://github.com/nextlyhq/nextly/pull/2076) [`6846b88`](https://github.com/nextlyhq/nextly/commit/6846b88b977c68583bdd4be4097384af634816da) @nextly-agent: On SQLite, an effect held until an enclosing transaction commits (an after hook, an event, a cache flush) now runs in the async context of the write that registered it.

  It used to run in the context of the code that ran the outer transaction, so the caller pinned around the write was gone. In particular a scoped API key's scope was lost: a hook's nested gated call was judged by the key's owner, a super-admin among them, instead of the key. The effect still runs outside the finished transaction. PostgreSQL and MySQL were not affected; they run such effects at once.

- [#2127](https://github.com/nextlyhq/nextly/pull/2127) [`43f7c5e`](https://github.com/nextlyhq/nextly/commit/43f7c5efaa0e1c30f56e4dc19be77eb519031d77) @nextly-agent: A dashboard with more cards than one save may carry can be saved again

  When a person regains access to many cards at once, their arrangement can hold more than one save may carry, in number of cards or in size. Saving now leaves out cards they have removed, latest first, until it fits. Cards still on their dashboard are never left out.
  
  `nextly/config` now also exports `layoutSizeProblem` and `MAX_LAYOUT_BYTES`, the layout endpoint's own check of whether a submission fits.
  
  Retired dashboard widgets now merge only into the same card of their successor, as the widgets specification describes, and a widget retired twice reaches the widget that exists now. This release retires no widget, so no dashboard changes yet.

- [#1972](https://github.com/nextlyhq/nextly/pull/1972) [`c753694`](https://github.com/nextlyhq/nextly/commit/c7536940991ac40a815df4b08202ce135b508881) @nextly-agent: A large backlog of due releases settled more slowly than it had to.

  Each drain pass decided which due releases it could mark published by looking every one of them up in the list of all due releases, so the decision grew with the square of the backlog: about two to four seconds at twenty thousand due releases, an imported schedule's size, taken out of a pass bounded to twenty or twenty-five.
  
  The pass now looks them up in a set built once, so the decision grows with the
  backlog. Which releases may settle, and in what order, is unchanged; a pass
  only reaches further into a large backlog before its deadline.

- [#1876](https://github.com/nextlyhq/nextly/pull/1876) [`14f98f7`](https://github.com/nextlyhq/nextly/commit/14f98f7c1d0444330509289895c22140b82a2425) @mobeenabdullah: A link saved in a pattern now points at the element the page actually shows.

  When a selection held a visible renamed element and a hidden copy with the same id (hidden by a visibility condition), a link from an unrelated block could keep the old id, which nothing renders once the hidden copy is left off the page. The visible element now decides where such a link points. A hidden element decides only when nothing visible carries that id, so a link and its target stay together when the condition later shows it. Hidden elements still get their own ids put back.

- [#2234](https://github.com/nextlyhq/nextly/pull/2234) [`841a74e`](https://github.com/nextlyhq/nextly/commit/841a74eec75b7cff3218a4ed39bca03a5ce1f24e) @nextly-agent: Adding Nextly to an app reached through a symbolic link (the project folder itself is a link) writes every file again.

  `nextly.config.ts`, directly in the project, was taken for a file written through a link and skipped, while the routes that import it were written. The project folder is now left out of that check for every file alike, and the folders below it are still checked.

- [#2280](https://github.com/nextlyhq/nextly/pull/2280) [`b2cf477`](https://github.com/nextlyhq/nextly/commit/b2cf477bd443e889748c9530e691aac5814b8c47) @nextly-agent: A localized single can be read in production.

  At a production boot, a code-first single was registered without its `localized` setting, so reads looked for its translated fields on its own table, where a migrated database does not keep them, and every read of a localized single (pages, the REST and Direct API, dashboard widgets) failed with "no such column". The dev server registered it correctly, which hid it. Singles are now registered as collections are.

- [#2015](https://github.com/nextlyhq/nextly/pull/2015) [`3bf9a8e`](https://github.com/nextlyhq/nextly/commit/3bf9a8edef4f72d93bdc7c5289f647b8ddb53c1e) @nextly-agent: A boot now refuses to start whenever a plugin's or the app's table that holds no rows yet lacks its foreign key to a collection's, Single's or field group's table, not only on the boot that created the tables, so a restart after a partly applied first boot no longer serves the table without its key.

  A table that holds rows is left to `nextly db:sync` and migrations. The first boot again attempts, and reports, a key to any other table that does not exist yet.

- [#2152](https://github.com/nextlyhq/nextly/pull/2152) [`810cc77`](https://github.com/nextlyhq/nextly/commit/810cc778909cf9a67734e704b8d913aa50b4d1a1) @nextly-agent: Adding Nextly to an app inside a monorepo now installs with the workspace's package manager, and approves its build scripts at the workspace's root.

  The workspace is the nearest one above the app, up to its repository's root, whose `pnpm-workspace.yaml` `packages` or `package.json` `workspaces` lists the app; its lockfile names the manager, or pnpm when its `pnpm-workspace.yaml` lists the app before the first install. An app inside a pnpm workspace no longer gets `npm install` from an `npx` launch, and an app no workspace lists keeps the manager that launched the generator, whatever lockfile sits above it. A new project keeps the manager that launched it. When the app already approves a specific esbuild version in pnpm's settings, that rule is kept, and the generator's note, which a failed install now repeats, says to approve the version pnpm names if the install then stops on another one.

- [#2146](https://github.com/nextlyhq/nextly/pull/2146) [`c3d368c`](https://github.com/nextlyhq/nextly/commit/c3d368cdafc07d84db578bd08f993d8ce018a22e) @nextly-agent: A new project starts Nextly when its server starts, so `/api/health` answers healthy on a freshly started server without waiting for other traffic, and code that reads through the `nextly` proxy finds it running.

  The blank and blog templates ship an `instrumentation.ts` that does it, and adding Nextly to an existing app writes one, or, when the app already has one, leaves it alone and prints the lines to add. Before Nextly has started, `/api/health` answers 503 with the generic error and logs, once, how to start it.

- [#2153](https://github.com/nextlyhq/nextly/pull/2153) [`028c4a0`](https://github.com/nextlyhq/nextly/commit/028c4a0c1c10876b983f86016eb204c58505f028) @nextly-agent: Every admin user now has a personal list of notices: things that happened to their work that they should know about.

  A notice is for one person only, is stored once per occurrence, keeps a snapshot of what it is about so it still reads after that is deleted, and records when it was read. Read them with `GET /api/notices`, count them with `GET /api/notices/count`, and mark them read with `POST /api/notices/read`, all for the signed-in person (API keys are refused); or from server code with `nextly.notices.find()`, `count()` and `markRead()`. Notices are kept as long as the activity log keeps its records, and are deleted with their person. Core features add their own kinds of notice through one registered, typed kind; plugins cannot add kinds yet. All of this is experimental.
  
  **Run `nextly migrate`** to create the `nextly_notices` table on an existing
  installation. Until then lists are empty, nothing is recorded, and one warning
  names the table and the command.

- [#2112](https://github.com/nextlyhq/nextly/pull/2112) [`07bf1f9`](https://github.com/nextlyhq/nextly/commit/07bf1f9b9d2f1cf073ca0eda870d45b2a62220a4) @nextly-agent: A plugin can contribute a full-screen view of a document, at its own address under the document's: `/admin/collections/<slug>/<id>/<path>`, or `/admin/singles/<slug>/<path>` for a Single.

  The admin keeps the document's loading, soft lock, recovered-work offer, leave guard and save shortcut around the view, so the plugin implements none of them. Declare a view for one collection under `contributes.admin.views[slug].documentViews`, or for every collection and Single with a top-level field of a type the plugin contributes under `contributes.admin.documentViews` with `fieldType`. `navigateTo` in `@nextlyhq/plugin-sdk/admin` moves to another admin page and refuses any other address. `PluginDocumentView`, `PluginFieldTypeDocumentView`, `DocumentViewProps` and `navigateTo` are experimental. Startup is refused when a view's path is malformed, reserved (`versions`, `api`), claimed twice on one document, or offered on a field type its plugin does not contribute.
  
  `nextly generate:types` now writes the plugin component import map, or removes a
  stale one, in an app that defines no schema in code. Such an app, whose schema
  is made in the Schema Builder, rendered empty plugin pages and settings
  components in a bundled admin, because the map was skipped with the types.

- [#2000](https://github.com/nextlyhq/nextly/pull/2000) [`3b7c77f`](https://github.com/nextlyhq/nextly/commit/3b7c77f0f16124f9632e8b953e5f7b43cf63c40f) @nextly-agent: A plugin can no longer declare a hook point under a prefix core's own seams use (`email.`, `admin.` and `collections.`): boot now fails with `hook-point-reserved`, naming the plugin and the point.

  A plugin whose slug is `admin` could otherwise declare `admin.nav` as a decision, and core's own admin navigation filter would then be refused on every request.

- [#2039](https://github.com/nextlyhq/nextly/pull/2039) [`e78ff48`](https://github.com/nextlyhq/nextly/commit/e78ff48a364bd92431c4e9398b5607c00edb82b2) @nextly-agent: A plugin can no longer turn an account into the kind core makes for a person another system has verified: active, verified and without a password.

  Through `ctx.services.users`, an update that would leave an account that way is refused with `FORBIDDEN` and changes nothing: verifying an active account that has no password, activating a verified one that has none, both at once, or reactivating one core made for a verified person. Core makes those accounts only when one of the app's login plugins hands it a sign-in. Such an account still takes a plugin's other edits, and a plugin may deactivate it or give it a password, but may not move it to another address unless the same write gives it a password. An active account with no password that is not verified stays allowed.

- [#2085](https://github.com/nextlyhq/nextly/pull/2085) [`b2e486d`](https://github.com/nextlyhq/nextly/commit/b2e486dfa1f513fdc9e797909c3e27dd30aa65d4) @nextly-agent: A plugin's email to one of its signed-in people can now name them: `ctx.services.email.send` and `sendWithTemplate` take `person`, by kind and id.

  The delivery record of the message keeps the person's kind and id, on the row of the `to` address, in new `principal_kind` and `principal_id` columns of `email_deliveries`. A `person` that cannot name one, or names a kind no running plugin registers, is refused before anything is sent.
  
  `ctx.principals.reportDeleted` now also erases, in the same transaction, the
  address digest on every delivery record naming the person, and names them
  there by the keyed digest of kind and id in place of the id. Every record
  written for the person afterwards, the email telling them their account is
  gone included, is written without an address digest, even when it is sent
  inside the report's transaction. Records of anyone else at the same address,
  a staff user's among them, stay as they were.
  
  Every delivery record is written through one insert, which takes the lock a
  deletion report waits on; a test holds any new writer to it. `nextly migrate`
  and boot add the columns and their index on all three databases, and rewrite
  no row.

- [#2073](https://github.com/nextlyhq/nextly/pull/2073) [`54292bd`](https://github.com/nextlyhq/nextly/commit/54292bdc543bb65baffcbd87538889307f136f54) @nextly-agent: A plugin can limit attempts on keys it chooses, before the work they guard runs (`@experimental`).

  It declares named rules in `contributes.limits`: rolling windows, or a growing wait after attempts in a row. The app refuses to start on a rule outside the documented bounds. The plugin then reserves through `ctx.limits` on 1 to 16 entries, each a rule and a key. A reservation is decided and counted atomically in the selected limit store, so instances sharing it admit no more than the rules allow. Each entry chooses whether it can refuse (`enforce`) and when it counts (`count`), and gets its own verdict back. `release`, `reset`, `check` and `clientKey` are there too. A store that can't answer gives `unavailable`, never an admission. Keys are stored only as keyed hashes, namespaced to the plugin. The types are exported from `@nextlyhq/plugin-sdk`.

- [#2003](https://github.com/nextlyhq/nextly/pull/2003) [`736748a`](https://github.com/nextlyhq/nextly/commit/736748a29d04e26f089b21b5ac7a45eb6916aafa) @nextly-agent: `ctx.auth.completeLogin` now refuses a plugin's strategy name that is only its slug and a colon (`acme-google-auth:`), with no name for the method after it.

  Such a login was recorded on the audit trail as the plugin alone, so a plugin with more than one way in could not be told apart on the rows. Name the method after the colon (`acme-google-auth:google`); plugins that already do are unaffected.

- [#2067](https://github.com/nextlyhq/nextly/pull/2067) [`07a3cf1`](https://github.com/nextlyhq/nextly/commit/07a3cf1d62a77b274e4434ab4b7bd7b3852d4643) @nextly-agent: A plugin-defined signed-in person can now read and write collections, under access rules and nothing else.

  Collection and field rules receive the person as `principal`, beside a `user` that is `null`, and collection hooks receive them too. A person is admitted only by a code-defined rule that names them, or one set to `true`: a collection with no rule for the operation refuses them, database permissions and the super-admin bypass never apply, and a person can never publish. Every other caller's rule receives `principal: null`.
  
  Collection and Single operations on the Direct API, and a plugin's
  `ctx.services` collection calls, take a `principal` option to run as a person.
  The call is refused before it runs if it also asks to skip access rules or
  the filterable-field check (`frameworkFilter`), names a staff user or an API
  key, or names a person core did not make; any other operation refuses the
  option, and it cannot be a Direct API instance default. `findSingles` made
  as a person lists, and counts, only the Singles the person may read. A document created as a person has no owner.
  `nextly.principals.byId` and `ctx.principals.byId` find a person through the
  kind's `lookup`, checked as a resolver's answer is.
  
  An update or delete by `where` made under access rules (a person's, or any
  call with `overrideAccess: false`) now refuses a `where` on a field the
  caller may not read before it writes anything, as a read already did; the
  rows it touched used to answer whether a guessed value matched.
  
  A dev reload now installs the kinds of signed-in person it reads only once it
  commits, and a kind's attributes may no longer be optional.
  
  `AccessControlContext` and a field rule's arguments gain a required
  `principal`, so code that builds either by hand adds `principal: null`.

- [#2078](https://github.com/nextlyhq/nextly/pull/2078) [`b9493fe`](https://github.com/nextlyhq/nextly/commit/b9493fe3ec23ceb82c763a406271e82e24a31305) @nextly-agent: A plugin can now name a person it signs in on an audit row, and erase them when it deletes them.

  `ctx.audit.write` takes `actorPrincipal` and `targetPrincipal`, by kind and id, kept in new columns of their own. Such a row keeps its network address, browser and metadata. A person's id in `actorUserId` or `targetUserId` is still stored as an erased account's, as before.
  
  `ctx.principals.reportDeleted(person, { tx })` erases a person from the audit
  trail in one transaction, the plugin's own when it passes `tx`. Core clears the
  address and browser on every row the person acted on, and the plugin metadata
  on every plugin row naming them. It names the person on those rows by a keyed
  digest of kind and id, in place of the id, and keeps only that digest and when,
  in the new `nextly_deleted_principals` table. Every row written about the
  person afterwards is written already erased. A report made inside an open
  transaction without passing it is refused.
  
  The digest's key is made once and kept sealed under `NEXTLY_SECRET`. After a
  rotation, keep the old secret in `NEXTLY_SECRET_PREVIOUS` until the app has
  started once with both, as for plugin settings.
  
  Every audit row now records which kind of caller acted, in `actor_kind`:
  `staff`, `apiKey`, `core`, `anonymous`, or the person's kind. Rows written
  before this release keep `NULL` there and read as before. `nextly migrate` and
  boot add the new columns, indexes and table on all three databases, and
  rewrite no row.
  
  `RequestActor` gains the `principal` type for a write made as a person. It
  carries the person's kind and id and no `id`, so the activity log, which
  records staff only, records no row for it. Webhook deliveries still report such
  a write as `system`, as they did before.

- [#2083](https://github.com/nextlyhq/nextly/pull/2083) [`d5fee41`](https://github.com/nextlyhq/nextly/commit/d5fee4133cdec199bc09c7c897ba258123a82018) @nextly-agent: A plugin's audit event naming a person of a kind no running plugin registers, a misspelled kind for instance, is now dropped with a warning, as one naming nobody is.

  No deletion report could reach such a row, so the network address, browser and metadata it kept would have outlived the person.
  
  The app now also refuses to start on a kind of signed-in person whose full
  name, `<plugin slug>:<name>`, is longer than the 191 characters an audit row
  stores it in. On PostgreSQL and MySQL every audit write naming such a person
  failed at the column, and the row was lost with only a log line.

- [#2087](https://github.com/nextlyhq/nextly/pull/2087) [`a8f3251`](https://github.com/nextlyhq/nextly/commit/a8f325173ce525b7649e8eb5b907f323239d20d6) @aqib-rx: A plugin settings request made with an API key now counts once against the key's rate limit.

  The settings route authenticated the key, then authenticated it again to check its permission, so each read or change of a plugin's settings spent two of the key's requests, and a key with one request left was refused with a 429. The permission is now judged on the caller the route already authenticated.

- [#2060](https://github.com/nextlyhq/nextly/pull/2060) [`e4e5088`](https://github.com/nextlyhq/nextly/commit/e4e508876d3b6ca2f64b87088ad915b32114f3da) @nextly-agent: A plugin can now sign in people who are not staff, such as a shop's customers or a site's members, without them ever reaching the admin.

  It registers its own kind of signed-in person in `contributes.principals`: a name, the attributes the kind exposes with their types, and a resolver that says who is signed in from the plugin's own session. Core names the kind `<plugin>:<name>`, and the app refuses to start on a kind whose name, attributes or resolver cannot be used, or whose name another plugin's kind already holds.
  
  Core calls a kind's resolver only where an entry point asks for that kind, and
  at most once per request. A plugin route asks with `principal: { kind }`. Its
  handler gets the person as `ctx.principal` and never staff: the route reads
  neither the staff session nor an API key, and `ctx.auth.currentUser` answers
  no user there. A route that requires the kind refuses a request with nobody of
  it with the same 401 any route gives an unauthenticated caller. A person's
  writes take the route's cross-site check as a staff session cookie's do,
  unless the request carries a `Bearer` token. `principal` cannot be combined
  with `public` or `requiredPermission`. Server code asks with
  `ctx.principals.fromRequest` or `nextly.principals.fromRequest`, which answer
  nobody to a write from another site. `nextly plugins info` lists each plugin's
  kinds.
  
  The surface is `@experimental` until the feature is complete.

- [#2049](https://github.com/nextlyhq/nextly/pull/2049) [`9064005`](https://github.com/nextlyhq/nextly/commit/9064005f83b13d6e1d678f8bc7c1d182513b5758) @nextly-agent: A plugin's update to an account core made for an externally verified person no longer fails when it carries the account's own address or verification date unchanged, as a full-object update does.

  Before, the unchanged address was judged as a move to another address and refused with `FORBIDDEN`, and the unchanged date was judged as a fresh verification, refused for the same reason and, on other accounts, restamped as vouched for by the plugin.
  
  An update that carries an account's address unchanged no longer writes it, so a
  stale update cannot put back an address another request has since moved the
  account from. An address stored in mixed case before addresses were lowercased
  is still rewritten in lowercase, so sign-in can find it. When an address moves,
  a verification date carried with it is recorded as the updater's vouching, not
  kept as whoever verified the old address.
  
  The update schema now takes a verification date as a `Date` or an ISO 8601
  date-time string, and refuses any other string with `VALIDATION_ERROR`. A string
  failed every update before, and `"0"` would have parsed as the year 2000.
  
  When a plugin's update is judged, a stored password counts as one when it is a
  hash core made, or, where the app passes its own `passwordHasher` to
  `getNextly`, whatever that hasher stored.
  
  The plugin services documentation now says that `ctx.services.users.create`
  refuses a missing password with `VALIDATION_ERROR` once an account exists, and
  with `FORBIDDEN` on an empty install.

- [#2005](https://github.com/nextlyhq/nextly/pull/2005) [`24f9dfd`](https://github.com/nextlyhq/nextly/commit/24f9dfd1d58eaea7c7d4a699737d73d71f10e71c) @nextly-agent: A write another site posts to a public plugin route with the session cookie is now recorded as a `csrf-failed` audit event naming the plugin and the route when the handler asks `ctx.auth.currentUser` who is signed in.

  The handler already got no user for such a write; the operator can now see the attempt, as they can when an authenticated route refuses one.

- [#2042](https://github.com/nextlyhq/nextly/pull/2042) [`b2e4ce0`](https://github.com/nextlyhq/nextly/commit/b2e4ce038c3eb91005ae55cc36ef6bfcc96b823d) @nextly-agent: When an app runs without the access-control service, a populated relationship now answers a signed-in person as a direct read does, from the person's permission grants, instead of returning every related row.

  Whether the caller may read related users is now asked once per read, however many relationships point at users, rather than once per field.
  
  An anonymous visitor no longer receives a populated user through a relationship: no
  visitor can read a user directly, and a user row carries the person's address. Visitors
  keep populated media, which public pages embed. A page your server renders with the
  Direct API reads as the system by default and still shows authors.

- [#2020](https://github.com/nextlyhq/nextly/pull/2020) [`5ba07e9`](https://github.com/nextlyhq/nextly/commit/5ba07e9db2dab60180e66489df4db4da04cbdc09) @nextly-agent: A relation a plugin's or the app's table declares is now checked when the app starts, once every table is registered.

  A relation to a Builder-made collection still resolves; one naming a table or column that does not exist stops the boot with `NEXTLY_SCHEMA_RELATION_UNRESOLVED`, naming each such relation, instead of failing the first relational query with an internal error. In development it is reported once the boot's schema apply has run, without stopping the dev server.

- [#2186](https://github.com/nextlyhq/nextly/pull/2186) [`d1fe962`](https://github.com/nextlyhq/nextly/commit/d1fe9629968d560383f410529d1e48d9b4055cec) @nextly-agent: A release now has a kind: `release`, the default, or `single`, a document scheduled on its own.

  Pass `kind` to `nextly.releases.create()` or `POST /api/releases`, and read it on every release. A single-document release holds exactly one document. The database refuses a second one, even when two requests add documents to it at once, with a conflict that says to use an ordinary release instead.
  
  `find()`, `count()` and `GET /api/releases` take a `kind` filter: `release` leaves
  out single-document releases and `single` lists only them. It filters on the
  stored kind, never on how many documents a release holds.
  
  `nextly migrate` adds the `kind` column (existing releases read as `release`) and
  a `single_slot` column on release documents with its unique index. No row is
  rewritten. `kind` is experimental.

- [#2185](https://github.com/nextlyhq/nextly/pull/2185) [`bd15d17`](https://github.com/nextlyhq/nextly/commit/bd15d178b211c7938b480837b12691a2be622267) @nextly-agent: A release can be published now: `POST /api/releases/:id/publish` and `nextly.releases.publishNow({ id })` run a draft or scheduled release at once, with the same checks as scheduling, by moving it to the current time (unless it is already due) and starting the job runner's releases run straight away, and answer the release as it then stands.

  When a releases run is already in progress, the release goes out with it (or with the next one) and the answer says it is still scheduled. Each release in a list also says whether the reader may publish it now (`can.publishNow`). Both are experimental. The capabilities type, `ReleaseCapabilities`, is now exported from `nextly/schemas`, and the admin reads its release controls from it.
  
  **Behaviour change:** a cancelled release is now final, like a published one.
  It can no longer be scheduled again, and documents can no longer be added to or
  removed from it; create a new release instead. A stopped release can still be
  fixed and scheduled again.

- [#2178](https://github.com/nextlyhq/nextly/pull/2178) [`c02c9f9`](https://github.com/nextlyhq/nextly/commit/c02c9f98b2b17670a3742138d667de586ca83acb) @nextly-agent: Adding a Single to a release no longer creates its row.

  The check that the document exists read the Single the ordinary way, which creates a Single's row the first time it is read, so a Single that was never saved was accepted, and a blank row was left behind even when the add was then refused. A Single that was never saved is now refused as not found, and nothing is written.
  
  The check also always reads as the caller: a Direct API instance's default API
  key can no longer stand in for a caller who brought no key of its own.

- [#2145](https://github.com/nextlyhq/nextly/pull/2145) [`a118daf`](https://github.com/nextlyhq/nextly/commit/a118dafe8ef277ffb272bcbf039cb273e5eabd07) @nextly-agent: A release now checks a document the same way whichever way it is added.

  The Direct API's `addMember` used to skip what the REST route checked, so a server caller could add a document that does not exist, one in a collection without drafts, or a Single by an id that is not its own. Those are now refused from every caller, with the same errors as over REST, and a Single is recorded by its own stored row. A trusted call (`overrideAccess`, the default) skips only the read permission.
  
  Finding the releases that are due is now one query on the release's state and
  time, instead of reading every scheduled release and filtering in memory, so a
  long list of future releases no longer slows each pass of the drain.

- [#2195](https://github.com/nextlyhq/nextly/pull/2195) [`ba18126`](https://github.com/nextlyhq/nextly/commit/ba181269bb1708918795d269ed33d900c1eabc26) @nextly-agent: A Single created in the admin can be added to a release from any server process.

  The release target check looked for the Single's row without first registering its table in the process handling the request, so a worker that booted before the Single existed refused a valid add with an error. It now resolves the Single the way every Single read does.

- [#2209](https://github.com/nextlyhq/nextly/pull/2209) [`c75ce69`](https://github.com/nextlyhq/nextly/commit/c75ce6960d703fba9dc19baff4ba0eccbd7ec756) @nextly-agent: A release moves only from the state it was judged in.

  Every change to a release (scheduling, cancelling, stopping, publishing, and adding or removing a document) now advances its revision, and scheduling compares against the one it read: a document removed while a release is being scheduled makes the schedule refuse, rather than leaving a single-document release scheduled with nothing. A release's capabilities now say what a single-document release allows: no scheduling while it is empty, no second document, and no removing its document once scheduled.
  
  For code calling `ReleasesRepository` directly: `scheduleRelease`'s
  `expectedRevision` (added as optional with Publish now) is now required, and
  `from` no longer has a default. A schedule fenced only on state cannot tell
  that a release changed under it, so every scheduling move must name the
  revision it read: read the release, then pass its `revision`.

- [#2215](https://github.com/nextlyhq/nextly/pull/2215) [`96686eb`](https://github.com/nextlyhq/nextly/commit/96686eb372973de731ccfb66c65fef364306a4c9) @nextly-agent: Adding Nextly to an existing app no longer writes a media or health route where a route handler the app already has answers that URL through a dynamic segment, such as `app/api/[...rest]/route.ts` or `app/api/[name]/route.ts`: the new, more specific route would have taken those requests from it.

  The route is reported, with its content to add, instead. Pages reached through a dynamic segment, such as a site's catch-all page, still leave the routes free to be written.

- [#2269](https://github.com/nextlyhq/nextly/pull/2269) [`0fdc420`](https://github.com/nextlyhq/nextly/commit/0fdc42019dfb41006f17a37ed63d44d304ee3388) @nextly-agent: A project from `create-nextly-app` whose config registers a plugin, such as the blog with its newsletter form, now installs that plugin in its `build` script, after `nextly migrate` and before `next build`.

  A production boot refuses a configured plugin that was never installed on its database, so a fresh blog project built for production answered every request with a 500 until `nextly plugins install` had been run there by hand. Installing again on each build is safe; each run adds one row to `nextly_schema_events`.

- [#2057](https://github.com/nextlyhq/nextly/pull/2057) [`9781341`](https://github.com/nextlyhq/nextly/commit/97813419118b0b16cc223b3cb8ffdd97b3c05ac9) @aqib-rx: A plugin's entity transform can no longer change what isn't its to change.

  A transform hands back a whole new definition of a collection, Single or
  component, so nothing stopped it from setting an option the entity's own
  definition is refused for, such as a collection's `db.idType` of `"serial"`,
  or from dropping another plugin's column. Each transformed entity is now
  validated as its definition would be, and a refusal names the plugin.
  
  A plugin transforming another plugin's entity must now list that plugin in
  `dependsOn`, so the owner can't be uninstalled from under the change. It can't
  move the entity to another table, and it can't remove a field, or change how
  one is stored, unless it declared that field. An access rule, hook, validator
  or admin setting a third plugin contributed stays unless the transforming
  plugin depends on that plugin too. A plugin that transformed another's
  collection without declaring the dependency stops at boot until it does.

- [#2077](https://github.com/nextlyhq/nextly/pull/2077) [`209b10c`](https://github.com/nextlyhq/nextly/commit/209b10c0806f9a92b94c35229df5d66cd1aa84ce) @nextly-agent: A two-factor challenge is answered once on every instance.

  The first correct answer now marks its sign-in answered in the database, in a new table `nextly_spent_challenges`, by one write only one caller can win, committed before the session is issued. Any later answer to the same pending token is refused, on whichever instance it reaches, before the challenge's `resolve` runs, and two correct answers at once mint one session. This holds whatever `rateLimit.store` is set to. The one-shot mark used to live in the rate-limit store, so with the default memory store an answered challenge could be answered again on another instance. A challenge pending across the upgrade is answered against the new mark.
  
  The attempt budget is counted in the limit store `rateLimit.store` selects, so with `"database"` wrong answers on every instance spend one budget. An app's own store of the earlier `RateLimitStore` contract keeps serving it. Its keys, which name an account, are stored only as keyed hashes, so a budget pending across the upgrade starts again. When the store can't be reached, or doesn't answer in time, a two-factor answer is refused with "Please try again shortly." (`503`) and no session is minted. The boot warning about a per-instance budget no longer appears when the database store is selected or `rateLimit.singleInstance` is set. Expired marks are removed as new ones are written, an hour after their token's end, so an answer let through before the end always meets them.
  
  `AuthRouterDeps` gains two optional members, `spentChallenges` and `limitStore`, so deps assembled by hand keep working: without `spentChallenges`, the mark stays in the rate-limit store as before. While an app's own store of the earlier contract is configured, the earlier release's mark is also kept there, so during a rolling upgrade instances of either release refuse a challenge the other settled. `nextly/auth` exports `databaseSpentChallenges` and `SpentChallenges` for such a caller to opt into the database's marks.

- [#2188](https://github.com/nextlyhq/nextly/pull/2188) [`c731d75`](https://github.com/nextlyhq/nextly/commit/c731d75f8a8eb6a803169de3067675fbf969c7f2) @nextly-agent: Two public types now match what Nextly accepts and returns.

  Code that calls them compiles as before. A hand-written stand-in for the `Nextly` interface, such as a test double, must now return `token` and `exp` from `login`, as the real one always has. - A job defined with a typed input, `defineJob<{ cursor: string }>(...)`, now goes into the config's `jobs` list, or a plugin's `contributes.jobs`, without a cast. Both lists now take any job, whatever input its handler takes. A value that is not a job is still refused, and a handler is still typed for its own input only, so it can't be called with another job's input. - `login()` on the `Nextly` instance from `getNextly()` is now typed as `LoginResult`, the user with the session `token` and its expiry `exp`, which is what it already returned. The documented `result.token` now type-checks.

- [#2197](https://github.com/nextlyhq/nextly/pull/2197) [`abe6ace`](https://github.com/nextlyhq/nextly/commit/abe6ace9622af4582016db060dcf3a2c9d18a49c) @nextly-agent: A comparison no longer offers a withdrawn version as what is published.

  The `published` baseline, and the comparison that opens on the current draft against what is published, now apply only while the language (or the part of the document with no language) is actually served: after an unpublish, or while a translation has never been published, `published` is refused with `NO_BASELINE` and the default opens on the version before. Whether a language is live is now decided in one place for every path, from the main row's status and the translation's own, and a translation's own status is read through one shared reader.

- [#2086](https://github.com/nextlyhq/nextly/pull/2086) [`39cd056`](https://github.com/nextlyhq/nextly/commit/39cd056b4f380394417191385c43097c5547f285) @nextly-agent: The accounts plugin names the account on every email it sends, so core erases the email's delivery record with the account when the account is reported deleted.

- [#2079](https://github.com/nextlyhq/nextly/pull/2079) [`bd8a2e9`](https://github.com/nextlyhq/nextly/commit/bd8a2e9bd2a831f83797697a241a5c1bf269abc6) @nextly-agent: The accounts plugin runs on core's own helpers: its password, one-purpose secret, opaque secret and limiter types come from the plugin SDK, and it declares its limit rules to core, which checks each when the site starts.

  It now refuses to start on a core that does not lend every helper it is built on, and, in production, on limit counts each instance keeps apart.

- [#2068](https://github.com/nextlyhq/nextly/pull/2068) [`6d1883d`](https://github.com/nextlyhq/nextly/commit/6d1883d31e4eed6fa963a01cdd8363a87fa69e46) @nextly-agent: Add `@nextlyhq/plugin-accounts`, site accounts for people who are not staff: sign-up with address verification, sign-in with per-address and per-client waits and known browsers, password reset, the holder's password and address changes, account deletion, the account kind of signed-in person, and REST endpoints for each, built on the account helpers core lends plugins.

- [#2084](https://github.com/nextlyhq/nextly/pull/2084) [`1c5fcee`](https://github.com/nextlyhq/nextly/commit/1c5fcee140dc76b622d388c7885dd8ad6d0e078d) @nextly-agent: The accounts plugin reports each account it deletes to core, in the transaction that deletes it, so core erases the account from the audit trail with the deletion.

  Staff actions name the account as the audit row's target person rather than in its metadata, and the plugin refuses to start on a core that does not lend it a way to report deletions.

- [#2082](https://github.com/nextlyhq/nextly/pull/2082) [`b2393fe`](https://github.com/nextlyhq/nextly/commit/b2393fe27b24bd7761ad6d7c0de1fd58d67e98e8) @nextly-agent: The accounts plugin's stand-in limiter gives a count back in the run the caller's reservation names for it, as core does, so a copy carrying stale runs gives nothing back.

- [#2081](https://github.com/nextlyhq/nextly/pull/2081) [`a18fade`](https://github.com/nextlyhq/nextly/commit/a18fade4d310d38ff8f88dac09350770932006d2) @nextly-agent: The accounts plugin's testing stand-ins keep core's contract: the limiter gives a count back only to the run it was counted in and refuses a malformed release before it reports a store outage, and one-purpose secrets never mint a code that repeats the live one it replaces.

- [#2134](https://github.com/nextlyhq/nextly/pull/2134) [`594d727`](https://github.com/nextlyhq/nextly/commit/594d7275a2a59d271916d239e3b2099602a8f613) @nextly-agent: Adding Nextly to an existing Next.js app now installs everything it needs and finishes, on npm, pnpm, Yarn and Bun.

  The app gets `esbuild` as its own dev dependency, so `nextly.config.ts` can be read and code-first collections load; before, npm left it nested under another package where Nextly could not find it. The install uses the package manager the app's lockfile names rather than the one that launched the generator, so `npx create-nextly-app` in a pnpm app no longer runs npm over pnpm's install.
  
  On pnpm and Bun, the install scripts of the native packages Nextly brings (the SQLite driver, esbuild and sharp) are approved before the install, in the one place the app's own version reads them and at its workspace root in a monorepo: `permissions` when `pnpm-workspace.yaml` has that section (pnpm 12.11+), `allowBuilds` (pnpm 10.26+), `onlyBuiltDependencies` (pnpm 10.6 to 10.25), `pnpm.onlyBuiltDependencies` in `package.json` (pnpm 10.0 to 10.5), and Bun's `trustedDependencies` when the app keeps that list. pnpm refused the install until then and the generator stopped with no config or routes written; Bun skipped the SQLite driver's install script and the app answered 500. Only Nextly's packages change: any approval or denial the app already wrote, version-scoped ones included, is kept, pnpm's own placeholder is replaced, and each change is printed. An `esbuild` the app already declares is left as it is. A project set up this way with an earlier version can add `esbuild` as a dev dependency and approve these packages the same way, then install again.
  
  When an install fails, the generator now names the exact command to run to see why.

- [#2189](https://github.com/nextlyhq/nextly/pull/2189) [`5eaabac`](https://github.com/nextlyhq/nextly/commit/5eaabac7756e765c17376e8b3eec4a3d8a35bb12) @nextly-agent: An admin page left open while the app's root layout is edited during `next dev` no longer fills the console with "Cannot read properties of null (reading 'removeChild')".

  The admin used to remove the host app's favicon links, which React owns, to show its own; it now switches them off and back without removing them, so React can keep updating `<head>`.

- [#2173](https://github.com/nextlyhq/nextly/pull/2173) [`fe36198`](https://github.com/nextlyhq/nextly/commit/fe361983d76fd2ff7426ba7a84fad28b304a2a87) @nextly-agent: The admin no longer writes its theme onto the host app's `<html>`, so an app whose root layout is the one `create-next-app` writes no longer logs a hydration mismatch on every admin page.

  The theme, light, dark or system, is applied to the admin's own containers, its dialogs and menus included, before the first paint, so a dark admin no longer flashes light on load. Native controls such as scrollbars and inputs follow the admin's theme.
  
  `ThemeProvider` and `useTheme` from `@nextlyhq/admin` keep their names and the `nextly-theme` storage key, and a `ThemeProvider` inside another still defers to the outer one. `ThemeProvider` now accepts `defaultTheme`, `storageKey`, `enableSystem`, `forcedTheme` and `nonce`; next-themes' other props, such as `attribute` and `disableTransitionOnChange`, are no longer accepted, since the admin no longer depends on next-themes. `useTheme` returns `theme`, `setTheme`, `resolvedTheme`, `systemTheme`, `themes` and `forcedTheme` as before.
  
  A site with a nonce-based Content Security Policy passes its nonce to `RootLayout` (`<RootLayout nonce={nonce} />`) or to `ThemeProvider`, so the admin's small pre-paint script is allowed to run.

- [#2181](https://github.com/nextlyhq/nextly/pull/2181) [`6eec099`](https://github.com/nextlyhq/nextly/commit/6eec099d96bffc7649485ff716bbb2eeaf00631b) @nextly-agent: `@nextlyhq/admin` exports `ThemePaintScript` and the `ThemeProviderProps` and `UseThemeProps` types.

  A host that puts `ThemeProvider` around admin content of its own renders `<ThemePaintScript />` first inside its `.nextly-admin` element, so a saved dark theme shows from the first paint. With `enableSystem={false}` and no `defaultTheme`, the admin now defaults to light rather than following the system setting.

- [#2182](https://github.com/nextlyhq/nextly/pull/2182) [`9c76108`](https://github.com/nextlyhq/nextly/commit/9c761085b0e68a2e7fe5e6f9598b239fd0f9f047) @nextly-agent: The admin's create-user and edit-user forms now accept exactly the passwords the server accepts.

  Any symbol counts, not only `@$!%*?&#.`, and a password over 128 characters is refused before it is sent. Every rule a password misses is listed, not only the first. Choosing "Set a password now" for a new user now says that they choose a new password the first time they sign in, which the server already requires.
  
  Setting a user's password through an update (the admin's edit form, the REST route or the Direct API's `users.update`) now stores the password exactly as given. It used to trim spaces from its ends before hashing, while account creation and sign-in did not, so a password whose symbol was a leading or trailing space could not be used to sign in. The admin's edit form no longer trims it either. A value of only whitespace still leaves the password unchanged. Existing password hashes are unaffected: only passwords written from now on change.

- [#2201](https://github.com/nextlyhq/nextly/pull/2201) [`40ce55b`](https://github.com/nextlyhq/nextly/commit/40ce55b9446bbff25ff3157209614c34e5164e78) @nextly-agent: `nextly/config` now exports the dashboard's widths (`WIDGET_WIDTHS`, `isWidgetWidth`, `widthFromSize` and the `WidgetWidth` type), and the admin reads them instead of keeping its own copy.

- [#2041](https://github.com/nextlyhq/nextly/pull/2041) [`8d6a0cb`](https://github.com/nextlyhq/nextly/commit/8d6a0cbd14b739c3e42476f7a972d2bf2f1200e7) @nextly-agent: The `afterDrizzle` escape hatch's refusals now point at what to use instead, and the documentation lists every construct it refuses.

  A check constraint or a foreign key returned by the hook is refused with a
  pointer to `checks` or `foreignKeys` on `defineTable`, an expression index to
  `indexes`, and an enum column to `col.enum()`. A partial index's refusal no
  longer promises a later release: partial indexes are refused on every dialect,
  because MySQL cannot build one.

- [#2100](https://github.com/nextlyhq/nextly/pull/2100) [`ca866a0`](https://github.com/nextlyhq/nextly/commit/ca866a0fc2029df5eb5c4c7b9b74e437036e5239) @nextly-agent: The AGENTS.md a new project or plugin receives names only exports that exist: `definePlugin` comes from `@nextlyhq/plugin-sdk`, not its `/admin` entry, and every sample imports the functions it uses.

  The generated-types stub no longer mentions a `nextly dev` command.

- [#2259](https://github.com/nextlyhq/nextly/pull/2259) [`2cfea22`](https://github.com/nextlyhq/nextly/commit/2cfea22947d17790e605679b03465a33078c4103) @nextly-agent: In an existing `.env.example`, a credential is flagged when its first word only begins like a placeholder word (`enterprise-…`), and when the database URL carries it in a query parameter whose name is percent-encoded (`?p%61ssword=…`).

- [#2238](https://github.com/nextlyhq/nextly/pull/2238) [`fc8c902`](https://github.com/nextlyhq/nextly/commit/fc8c902808b85f21651854f0e45c49305eb2739b) @nextly-agent: The `nextly.config.ts` written when Nextly is added to an existing app keeps its placeholder comments where they apply: `// Add your collections here` directly above `collections`, and `// Add your singles (globals) here` directly above `singles`, inside the exported config.

  Both used to run together on one line above `export default`.

- [#2225](https://github.com/nextlyhq/nextly/pull/2225) [`224e51a`](https://github.com/nextlyhq/nextly/commit/224e51a1657558916fe0990d25134b90a0ca3e48) @nextly-agent: Adding Nextly to an existing app no longer writes through a folder on a file's path that is a symbolic link.

  When the app's `app/admin`, `types/generated` or another folder Nextly writes into is a link to a directory elsewhere, nothing is written there, which could have been outside the project. The file is reported as left alone, with the folder that is a link and, for the admin's files, the content Nextly needs, as the media routes already did.

- [#2151](https://github.com/nextlyhq/nextly/pull/2151) [`7dfb74b`](https://github.com/nextlyhq/nextly/commit/7dfb74bdf4c7e9bb74c8afb6c635d42189e110a9) @nextly-agent: Adding Nextly to an existing Next.js app now writes the admin's media route under `/admin`, the same file a new project gets, so uploads, edits and deletes in the admin's media library work.

  Every request to it is checked against the signed-in caller's media permissions; the public media route stays read-only. The routes import the Nextly config by a path relative to themselves, so no import alias is needed. A media or health route the app already has is left exactly as it was and reported, and for the admin's route the report includes the content to add. A route is not written where a Pages Router page answers its URL (in `pages/` or `src/pages/`), since Next.js refuses to build a page and an app route at one URL; that is reported in the same way.

- [#2216](https://github.com/nextlyhq/nextly/pull/2216) [`b766e82`](https://github.com/nextlyhq/nextly/commit/b766e8239c7d86ccd9ad1ed82b983675ed2e2573) @nextly-agent: Adding Nextly to an existing app no longer replaces or refuses over files the app already has, and the run ends with one report of everything it did.

  Before, a `nextly.config.ts`, admin API route or admin page already there stopped the whole run with an error, and the admin's `layout.tsx` was written over whatever the app had.
  
  Now every file is created only where nothing is (exclusively, so a link or a file that appears meanwhile is never replaced). One already there is left byte for byte, and the report says so, with why and, for the admin's routes, page and layout, the content Nextly needs there. The report lists each file written, each extended with what was added (the Next config's packages included), and each left alone, across every step, the env files and their warnings included, in one place after the spinner, by path, key and package name only: no env value, the generated secret included, is ever printed.

- [#2019](https://github.com/nextlyhq/nextly/pull/2019) [`8561690`](https://github.com/nextlyhq/nextly/commit/8561690befe9d08634e9c7bc9d7de2d22fe6ece8) @nextly-agent: A role given with an expiry date now stops granting anything once that date passes.

  Until now the expiry was ignored almost everywhere: an expired role kept its permissions in every check, an expired super-admin assignment kept super-admin authority, and login and session refresh still put the role in the session's claims. Cached permission answers, super-admin answers and API key grants now also end no later than the expiry of a role they came from, and a session's access token ends no later than the first of its roles to lapse, so the client refreshes into claims without it.
  
  Assigning a role whose earlier assignment has expired now gives it again,
  instead of answering that it is already assigned. Listing a user's roles shows
  only the roles in force. If a user's roles cannot be read at login or refresh,
  the session is refused rather than issued with no roles.
  
  Permission answers cached in the database before this release are no longer
  read: they could have come from a role that has since expired. They expire and
  are cleared as usual, and the first check of each permission after the upgrade
  is worked out afresh.
  
  Breaking, for code that builds its own `AuthRouterDeps` for
  `routeAuthRequest` (`nextly/auth`, experimental): the `fetchRoleIds(userId)`
  dependency is replaced by `fetchSessionRoles(userId)`, which returns
  `{ roleIds, validUntil }` (the `SessionRoles` type, now exported):
  the role ids in force, and the soonest expiry among them in epoch milliseconds,
  or `undefined` when none expires. Read only assignments whose `expires_at` is
  empty or later than now. The access token is bounded by `validUntil`.

- [#2105](https://github.com/nextlyhq/nextly/pull/2105) [`fdff3a8`](https://github.com/nextlyhq/nextly/commit/fdff3a8da69228989eec2ab7e7fdda6a87722f5a) @nextly-agent: Moving a block into a component instance with Alt+ArrowRight no longer makes it vanish.

  A component instance is not a registered block, so the keyboard move could not read its slots and filed the block under the engine's default slot, which an instance draws only when its component exposes it. The move now reads the slots the instance's component exposes and places the block in one of them, and offers no move when the component exposes several and none is the default.

- [#2095](https://github.com/nextlyhq/nextly/pull/2095) [`d69c112`](https://github.com/nextlyhq/nextly/commit/d69c112db7b48dadfa075e3e901492562920f986) @nextly-agent: Moving a block into the container above it with Alt+ArrowRight no longer makes it vanish.

  The keyboard move filed the block under a slot name of its own that no core container renders, so the block stayed in the page's data, selected in the inspector and listed in the breadcrumb, while the canvas showed nothing. It now lands in the default slot every core container declares and renders.
  
  Each row of the Layers panel also names the block it stands for in a
  `data-nx-layer` attribute, as the canvas already does with `data-nx-node`. Row
  labels repeat (every paragraph is "Text"), so a test or an integration had no
  stable way to find one block's row.

- [#2203](https://github.com/nextlyhq/nextly/pull/2203) [`59a1e9a`](https://github.com/nextlyhq/nextly/commit/59a1e9adc0d2510a8e6f45c1649bd71e159bddc5) @nextly-agent: A job written inline in the config's `jobs` list or a plugin's `contributes.jobs`, as an object rather than through `defineJob`, keeps `JobInput` as the type of its handler's input, alongside jobs defined with a typed input, which fit those lists without a cast.

- [#2264](https://github.com/nextlyhq/nextly/pull/2264) [`43f48ed`](https://github.com/nextlyhq/nextly/commit/43f48ed581b4fdccb604088b6b4f9f29635c4978) @nextly-agent: Back and Forward with unsaved changes now ask before leaving in a real browser.

  In Chromium the editor's guard heard the history move only after the app's router had already drawn the next page, so pressing Back on a dashboard in customise mode, an entry or a single with unsaved changes left without asking and lost the changes. The guard now listens from the moment the admin loads, ahead of any router.

- [#2156](https://github.com/nextlyhq/nextly/pull/2156) [`d03cde4`](https://github.com/nextlyhq/nextly/commit/d03cde4efc6340656dc4e8ecaf27a06c4bc8477a) @nextly-agent: In a new blog project, clearing the search box and typing a new query no longer shows the old query's results while the new search loads, and "No results" waits until the search has answered.

- [#2030](https://github.com/nextlyhq/nextly/pull/2030) [`523adc3`](https://github.com/nextlyhq/nextly/commit/523adc317e21cab0b029cd08706d08578666b4b1) @nextly-agent: In development, a boot that cannot apply its schema, and the check that every declared relation resolves before plugins initialise, now log the error with its code, its cause chain and its log context, through the same description every other operator log uses.

  Before, they printed the message alone, so a refused relation's report left out the code and the relations it named.

- [#2017](https://github.com/nextlyhq/nextly/pull/2017) [`774d4c5`](https://github.com/nextlyhq/nextly/commit/774d4c54329ae3e9061d168fc4ee0c5a07718977) @nextly-agent: A Schema Builder relationship's foreign key works for a camelCase field, a long name, and a relationship an older save stored.

  On SQLite, a relationship named in camelCase (`leadAuthor`, stored in
  `lead_author`) gave the table drizzle-kit rebuilds a key over a missing
  column. A long collection and field name made a key name MySQL refuses past
  64 characters; it is now shortened with a hash past 63, as other derived names
  are. And a required relationship that nulls on delete, stored by an older
  save, no longer refuses every later save of its collection.

- [#1991](https://github.com/nextlyhq/nextly/pull/1991) [`285b88b`](https://github.com/nextlyhq/nextly/commit/285b88bde0f5ac2690eb98041d70910ca33f5fba) @nextly-agent: Admin: a schema-builder page is no longer shown before the server has said the builder is on.

  Opening a builder address where the builder is off now goes to the dashboard without the editor appearing first, and a failed request shows a message instead of the builder.

- [#2033](https://github.com/nextlyhq/nextly/pull/2033) [`436b3bd`](https://github.com/nextlyhq/nextly/commit/436b3bddaf0d97a295c0bf20a78b1632bcb712d2) @nextly-agent: A Schema Builder one-to-one relationship stays unique when a save changes its table, and a SQLite save no longer drops a table's triggers.

  Adding a relationship to a Builder collection, or changing a field's type on
  SQLite, rebuilt or altered the table in a way that dropped the UNIQUE the
  Builder gives a one-to-one relationship's column, on SQLite and PostgreSQL, so
  the column then accepted duplicates. While a table held such a column, a
  SQLite save of another collection could also be refused. Both now keep the
  constraint. A SQLite save that would rebuild a table with triggers on it is
  refused, naming the table, instead of dropping the triggers; make that change
  with a migration that recreates them.

- [#2014](https://github.com/nextlyhq/nextly/pull/2014) [`6e12fe5`](https://github.com/nextlyhq/nextly/commit/6e12fe518fda967b429f579f9d33f3166b250cb6) @nextly-agent: A Schema Builder collection keeps its foreign keys, and gets the key of a relationship added to it.

  The Builder creates a collection's table with each relationship's key, but
  every later save went through the schema push, which described no keys. So a
  relationship added to an existing collection got no key on any database. On
  SQLite, where a key lives only in the table's definition, a save that changed
  a field's type rebuilt the table without its key, and a save that rebuilt a
  collection other collections point at failed outright. A save now adds the key
  of a relationship it adds, and keeps every key a table already holds through a
  rebuild, with no delete action running during it. A relationship that was
  added before this and has no key is left as it is.

- [#2040](https://github.com/nextlyhq/nextly/pull/2040) [`4e27dce`](https://github.com/nextlyhq/nextly/commit/4e27dce8fcdffa28f5283652f03b9fcb017fd343) @nextly-agent: A repeater or group on a table the Schema Builder creates is now stored in the database's JSON type (`jsonb` on PostgreSQL, `json` on MySQL), as on every code-first table.

  That covers Builder collections and singles, and the singles `nextly db:sync` creates itself. They were created as `text`, while the schema `db:sync` compares against names the JSON type, so each such table was offered a conversion its author never asked for. Tables created from now on match.
  
  Existing tables keep their `text` columns: this changes no stored data, and
  `db:sync` still offers to convert them. That conversion succeeds when every
  stored value is valid JSON. It refuses the whole apply when one is not (an
  empty string, for example), leaving the column as it was. SQLite is unchanged:
  it stores JSON as text either way.

- [#2051](https://github.com/nextlyhq/nextly/pull/2051) [`7076ae4`](https://github.com/nextlyhq/nextly/commit/7076ae4811bdacaf730dca6556afad0644dbbf60) @nextly-agent: New command: `nextly users:clear-plaintext-passwords`.

  Earlier releases stored some passwords as typed instead of hashing them (32 characters or more containing a colon, given when an account was created, or any value sent to `PATCH /api/users/:id/password`). Such a value never let anyone sign in, but it is readable in the database and its backups.
  
  Run the command once the upgraded version serves every instance, since an old instance
  can still store a password as typed. Without flags it only counts the affected accounts;
  with `--apply --confirm-default-hasher` it clears each stored value and ends that account's
  sessions, printing only a count: none can be renewed, and an access token already issued
  lasts until it expires, within fifteen minutes by default. `--apply` alone changes nothing: the second flag is your
  statement that no stored password was written by a custom `passwordHasher`, now or earlier,
  since the command cannot see one and would take its hashes for plain text. Values in a format other
  hashers write (an argon2, scrypt or pbkdf2 string in the modular-crypt form their libraries
  write, or a hex salt and digest joined by a colon) are never counted or cleared; a value that
  only starts with `$` is. `nextly migrate` now prints the same count and names the command, and changes
  nothing itself. People whose password was cleared are refused at sign-in exactly as
  before, and set a new password with Forgot password, or an administrator sets one.

- [#2096](https://github.com/nextlyhq/nextly/pull/2096) [`12d1c70`](https://github.com/nextlyhq/nextly/commit/12d1c70c355742645c4c5276d2aa38f0b81bd4fd) @nextly-agent: `nextly/config` no longer pulls `node:crypto` into a browser bundle.

  It published three dashboard layout limits from a module that also builds a hash with `node:crypto`; the limits now come from a module with no imports, and keep their values.
  
  Every entry listed as safe for a browser is now checked: a test walks each
  one's runtime imports and fails, naming the entry and the path, if any reaches
  a Node built-in, a database package or a module marked with `server-only`. The
  database-operator re-exports, the table DSL and the stored-bytes helper reach
  server-only code by design and are imported only from server code, so they are
  listed as server entries; this changes no build output.

- [#2029](https://github.com/nextlyhq/nextly/pull/2029) [`918f7f0`](https://github.com/nextlyhq/nextly/commit/918f7f0505669b9fede1fc832ae202d77118f459) @nextly-agent: A collection's `db` options are now checked where the collection is defined.

  Before, an `idType` the write path did not recognise gave UUIDv4 ids in silence, and an `allowIdOnCreate` that was not `true` discarded every id a caller supplied, and any other name in `db` was not read at all. Now an `idType` other than `uuid` or `uuidv7`, an `allowIdOnCreate` that is not a boolean, a name that is not an option (a misspelling such as `idTyp`), and `db` options that are not an object are refused, naming the option.
  
  `serial`, which a config carried over from Payload may hold, is refused with
  its own message: a collection's id is text, which every relation and API path
  reads. A sequential number, such as an order number, goes on a table keyed by
  `col.serial()`.

- [#2064](https://github.com/nextlyhq/nextly/pull/2064) [`92317b6`](https://github.com/nextlyhq/nextly/commit/92317b6b85e63e42e44c1b2b55b9060e387e6676) @nextly-agent: `@nextlyhq/plugin-commerce`'s checkout, still `@experimental` and unpublished, refuses a line past what its item now allows and an order whose jobs went stale while the catalogue was read, retries a payment's start only with the provider it began with, holds catalogue names and SKUs to 255 characters, and keeps provider references that differ only in case apart on every database.

- [#2052](https://github.com/nextlyhq/nextly/pull/2052) [`1b957e9`](https://github.com/nextlyhq/nextly/commit/1b957e9de798dfa2bfcd9d6dc9dc7b69d666f38d) @nextly-agent: `@nextlyhq/plugin-commerce` gains the plugin itself, `commerce()`, still `@experimental` and unpublished: its own tables and migrations, a built-in products collection to sell from or a catalogue source of the site's own, and a cart kept on the server behind an HTTP-only cookie, with REST routes and typed server functions for a storefront.

  Catalogue sources can report items sold to order, which hold no stock and are capped per line.

- [#2056](https://github.com/nextlyhq/nextly/pull/2056) [`8c6956f`](https://github.com/nextlyhq/nextly/commit/8c6956fb5eed9a8ba7663523955fd0d5d0ff9eca) @nextly-agent: `@nextlyhq/plugin-commerce` gains checkout, still `@experimental` and unpublished: a cart is quoted from the catalogue's own prices and completed into one numbered order, with its stock held while it is unpaid and its payment started through the store's payment provider, never twice, even when a request is repeated or the provider is down.

  Checkout takes no orders until commerce's jobs have run, which is a launch step. Stock and order records keep catalogue references apart exactly, letter case included, on every database.

- [#2249](https://github.com/nextlyhq/nextly/pull/2249) [`e1e391b`](https://github.com/nextlyhq/nextly/commit/e1e391b3e5e90243220389ec1885061d6bb22419) @nextly-agent: A comparison can start from the version you last looked at.

  A client marks a version as seen with `PUT …/versions/{n}/seen` (people only); the admin doesn't send it yet. API keys and the system are refused, and no read records anything. Marks count by visit: a mark more than 30 minutes after a marker's last one starts a new visit, and "last looked at" is the furthest version of the previous visit, kept per person, document and language. The `last-viewed` baseline compares from it, falling back to the nearest older version when retention removed it and naming the one removed, and the history list marks the row where you last looked. Markers live in a new core table, `nextly_version_views`, which `nextly migrate` creates, and go with their document, collection, Single or person.

- [#2125](https://github.com/nextlyhq/nextly/pull/2125) [`e356b75`](https://github.com/nextlyhq/nextly/commit/e356b753086c530949efa44e7c64dae9d986d8ee) @nextly-agent: A version comparison now names fields the way the editor does.

  Each field carries the label it declares (`declaredLabel`, or `null` when it declares none, so a reader applies its own naming rule instead of showing the field's key) and its place among containers (`path`), including groups with no name, whose heading was lost before. A field the reader may not read is reported in its place as `hidden`, with nothing of its values, instead of disappearing. Keys with no field are marked `system` (a framework column such as when the document was first published) or `removed`, and removed fields are listed last. A comparison with withheld fields is marked `incomplete`, so "no changes" then means only that nothing the reader may read changed; a changes-only comparison never lists a hidden field. The new response fields are experimental.
  
  Compatibility: `FieldDiff` gains the `hidden` member. Code that switches over
  `kind` exhaustively needs a `hidden` case (or a default) after upgrading; the
  admin draws it as "Hidden: you don't have access to this field".

- [#2148](https://github.com/nextlyhq/nextly/pull/2148) [`4358d85`](https://github.com/nextlyhq/nextly/commit/4358d8586a085bc650f3ab5c398ac7b3500c96a3) @nextly-agent: A version comparison can start from a baseline the server works out, instead of two version numbers: `?to=6&baseline=previous` compares version 6 with the version before it in the same language, `published` with that language's latest published version, and `draft` with its current draft.

  With neither a `from` nor a `baseline`, a comparison opens on the current draft against what is published when both exist, and otherwise on a version against the one before it. A language's first version is refused with `NO_BASELINE`, and the response names the baseline it used. Core features can register their own baselines. Comparing two named versions works as before. Baselines are experimental.

- [#2128](https://github.com/nextlyhq/nextly/pull/2128) [`2011f67`](https://github.com/nextlyhq/nextly/commit/2011f67edc438869e7f5740f46b35b4b8bb1fe29) @aqib-rx: A many-to-many relation inside a component was stored as a column on the component's row, but the schema planner did not expect that column.

  A push over such a component could plan to drop the column that stores the link. The planner now expects the same column the component table is created with, so a push, migration, or introspection finds no difference.

- [#2094](https://github.com/nextlyhq/nextly/pull/2094) [`b24afb3`](https://github.com/nextlyhq/nextly/commit/b24afb3369d40b1a4bab1a9e7c7855f667b763a0) @nextly-agent: A page builder document is now judged as a reader receives it, on every write path — the editor, the API, a batch, and an import that skips hooks: - a component save that would make the component contain itself is refused, naming the chain; - a document whose components put a block where the nesting rules forbid, or nest deeper than the depth limit counted from the top of the document, is refused, naming the placement; - a component change that would break a page, Layout or component placing it is refused, saying how many documents it would break and which rule.

  The documents themselves are named only in the server log, since they were found and read as the system. Documents the usage index cannot vouch for are reported in the server log as unjudged.
  
  Plugin field types gain an experimental `write` argument on `validate`: which
  entry the write is for, and `readStored`, a read of stored values of the
  validator's own type on the write's own connection — so a batch's own rows are
  seen. The page builder's former cycle-guard `beforeChange` hook is removed;
  bulk component writes with hooks running are no longer refused for being in a
  transaction.

- [#2263](https://github.com/nextlyhq/nextly/pull/2263) [`e4bab4c`](https://github.com/nextlyhq/nextly/commit/e4bab4ca033d1c214742bf6c48457b0176e2896b) @nextly-agent: Under `next dev`, a saved config that imports a file in a folder that does not exist yet now applies once that folder and file are created.

  Before, the import was seen only on the next save of the config.

> Notes truncated to stay within GitHub's release body limit. The complete changelog for every package is in its `CHANGELOG.md` at [v0.0.2-alpha.67](https://github.com/nextlyhq/nextly/tree/v0.0.2-alpha.67).

### Packages

- `@nextlyhq/adapter-drizzle`
- `@nextlyhq/adapter-mysql`
- `@nextlyhq/adapter-postgres`
- `@nextlyhq/adapter-sqlite`
- `@nextlyhq/admin`
- `@nextlyhq/admin-css`
- `@nextlyhq/blocks-engine`
- `@nextlyhq/blocks-react`
- `@nextlyhq/builder`
- `@nextlyhq/eslint-plugin`
- `@nextlyhq/plugin-form-builder`
- `@nextlyhq/plugin-mcp`
- `@nextlyhq/plugin-page-builder`
- `@nextlyhq/plugin-sdk`
- `@nextlyhq/plugin-seo`
- `@nextlyhq/storage-s3`
- `@nextlyhq/storage-uploadthing`
- `@nextlyhq/storage-vercel-blob`
- `@nextlyhq/ui`
- `create-nextly-app`
- `nextly`

<!-- /release-notes -->

Previous release: [0.0.2-alpha.66](https://nextlyhq.com/changelog/0.0.2-alpha.66) · Next release: [0.0.2-alpha.68](https://nextlyhq.com/changelog/0.0.2-alpha.68)

## Earlier releases

Releases before 0.0.2-alpha.66 are listed in [Nextly's changelog on GitHub](https://github.com/nextlyhq/nextly/blob/main/packages/nextly/CHANGELOG.md).
