0.0.2-alpha.67

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.

Before you upgrade1

  1. 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:

    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:

    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.

What's changed90

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

Patch Changes

  • 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.
    More

    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.

  • A class whose id was __proto__ could not be renamed again after a refused save.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • Retention never removes a version a feature has pinned.
    More

    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.

  • 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.
    More

    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.

  • A filter that is not well formed is now refused instead of being run without its bad part.
    More

    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.

  • Database adapters can tell whether the calling async flow is inside a transaction that has not finished yet, with transactionOpenInFlow() (@experimental).
    More

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

  • 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.
    More

    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.

  • 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.
    More

    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.

  • A dashboard with more cards than one save may carry can be saved again
    More

    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.

All 90 changes
  • A large backlog of due releases settled more slowly than it had to.
    More

    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.

  • A link saved in a pattern now points at the element the page actually shows.
    More

    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.

  • Adding Nextly to an app reached through a symbolic link (the project folder itself is a link) writes every file again.
    More

    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.

  • A localized single can be read in production.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • Every admin user now has a personal list of notices: things that happened to their work that they should know about.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • A plugin can limit attempts on keys it chooses, before the work they guard runs (@experimental).
    More

    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.

  • 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.
    More

    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.

  • A plugin-defined signed-in person can now read and write collections, under access rules and nothing else.
    More

    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.

  • A plugin can now name a person it signs in on an audit row, and erase them when it deletes them.
    More

    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.

  • 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.
    More

    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.

  • A plugin settings request made with an API key now counts once against the key's rate limit.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • A relation a plugin's or the app's table declares is now checked when the app starts, once every table is registered.
    More

    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.

  • A release now has a kind: release, the default, or single, a document scheduled on its own.
    More

    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.

  • 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.
    More

    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.

  • Adding a Single to a release no longer creates its row.
    More

    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.

  • A release now checks a document the same way whichever way it is added.
    More

    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.

  • A Single created in the admin can be added to a release from any server process.
    More

    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.

  • A release moves only from the state it was judged in.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • A plugin's entity transform can no longer change what isn't its to change.
    More

    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.

  • A two-factor challenge is answered once on every instance.
    More

    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.

  • Two public types now match what Nextly accepts and returns.
    More

    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.

  • A comparison no longer offers a withdrawn version as what is published.
    More

    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.

  • 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.
  • 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.
    More

    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.

  • 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.
  • 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.
    More

    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.

  • 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.
  • 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.
  • Adding Nextly to an existing Next.js app now installs everything it needs and finishes, on npm, pnpm, Yarn and Bun.
    More

    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.

  • 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')".
    More

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

  • 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.
    More

    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.

  • @nextlyhq/admin exports ThemePaintScript and the ThemeProviderProps and UseThemeProps types.
    More

    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.

  • The admin's create-user and edit-user forms now accept exactly the passwords the server accepts.
    More

    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.

  • 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.
  • The afterDrizzle escape hatch's refusals now point at what to use instead, and the documentation lists every construct it refuses.
    More

    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.

  • 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.
    More

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

  • 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=…).
  • 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.
    More

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

  • Adding Nextly to an existing app no longer writes through a folder on a file's path that is a symbolic link.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • A role given with an expiry date now stops granting anything once that date passes.
    More

    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.

  • Moving a block into a component instance with Alt+ArrowRight no longer makes it vanish.
    More

    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.

  • Moving a block into the container above it with Alt+ArrowRight no longer makes it vanish.
    More

    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.

  • 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.
  • Back and Forward with unsaved changes now ask before leaving in a real browser.
    More

    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.

  • 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.
  • 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.
    More

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

  • A Schema Builder relationship's foreign key works for a camelCase field, a long name, and a relationship an older save stored.
    More

    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.

  • Admin: a schema-builder page is no longer shown before the server has said the builder is on.
    More

    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.

  • 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.
    More

    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.

  • A Schema Builder collection keeps its foreign keys, and gets the key of a relationship added to it.
    More

    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.

  • 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.
    More

    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.

  • New command: nextly users:clear-plaintext-passwords.
    More

    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.

  • nextly/config no longer pulls node:crypto into a browser bundle.
    More

    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.

  • A collection's db options are now checked where the collection is defined.
    More

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

  • @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.
  • @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.
    More

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

  • @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.
    More

    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.

  • A comparison can start from the version you last looked at.
    More

    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.

  • A version comparison now names fields the way the editor does.
    More

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

  • 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.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

  • 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.
    More

    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.

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

Earlier releases

Releases before 0.0.2-alpha.66 are listed in Nextly's changelog on GitHub.