Repository navigation
v0.0.2-alpha.69
Pre-releaseBefore you upgrade
1. Run nextly migrate after upgrading, before content can be saved.
npx nextly migrateEvery save now records its history, and until the history table exists a save is refused with "The database needs updating before content can be saved. Run nextly migrate." A production server never creates tables itself. The same run also creates the table your user fields are stored in (user_ext) on a database set up by migrate alone, creates the nextly_preview_links table (until it exists, sharing a new preview link is refused with the same remedy; links made before the upgrade keep working until they expire), adds the releases table's two new indexes, and adds the email templates table's new cc, bcc and carries_secret columns. It also adds the email_send_claims table, the email delivery log's key_digest column, and the email_brand table that holds the email brand settings (until it exists, emails send as before but saving the brand settings fails). It also creates the nextly_languages table that holds languages managed through /api/languages; until it exists the site reads as having none stored, and changing them fails. Until these exist, templated email (password reset, verification, welcome, plugin emails) and the admin's email templates fail, deleting a user fails, and the email delivery log is not written, so run nextly migrate before the new version serves traffic, or set db.runMigrationsOnBoot.
2. Self-registration is now off by default. If your users sign themselves up, add this to your Nextly config before upgrading, or registration answers 403 "Registration is not open.":
auth: { allowRegistration: true }An account already registered and waiting for verification can still finish by its link. No migration is needed.
3. Make sure someone holds the Super Admin role. Setup now stays open until an account holds Super Admin, instead of closing at the first account, and a registered account never becomes the first administrator. On an install that has accounts but where nobody holds Super Admin, give one of them the role before upgrading. An administrator whose role inherits Super Admin counts.
4. In production, creating the first administrator needs a one-time setup token. An install that already has an administrator sees no change. On an empty production install, the next start prints one line beginning [nextly] SETUP TOKEN with the token and the setup address (valid for 24 hours and one setup), and the setup form asks for it. Automation that creates the first administrator over HTTP in production must set NEXTLY_SETUP_TOKEN (at least 43 characters; nextly setup-token --generate prints one). Or create the administrator from the command line with the new nextly users:create-admin. No migration is needed.
5. App tables can no longer be named accounts or sessions. These are retired auth table names, and db.schema.extend refuses them. Rename such a table before upgrading.
6. MySQL, unique custom user fields. A unique text user field without maxLength, built-in or from a plugin, is now stored as varchar(255) so its unique index can be made, and a longer value is refused before it is written. On an install where such a column was created as TEXT, nextly migrate stops and names the field: change the column to VARCHAR(255) once no value is longer, or remove unique from the field, then run it again. Values are never truncated.
7. PostgreSQL and MySQL, components with a many-to-many relationship. That column is now the JSON list column a hasMany relationship uses. On an existing install, the next schema apply offers a one-time conversion of a component table holding it as uuid or text (PostgreSQL) or varchar(36) (MySQL), and nextly migrate:create writes the same conversion into a migration file. Every stored id is kept. Until the conversion is accepted, saves to such a column may be refused. SQLite needs no conversion.
8. Email templates now have owners. Nothing an existing site sends changes on upgrade, with one exception (below): a stored core or plugin template that differs from its owner's version is kept as the site's override, and a site that configures email.templates keeps sending the template it sends today until it resets it. The exception: a site that sends its email.templates version because it deactivated the stored template now sends it with that stored template's provider, From, Reply-To and attachments, where it had set them. Core templates and an installed plugin's can no longer be deleted; reset them instead. Code that builds an EmailTemplateRecord itself (a test fixture, a mock of the template service) must now set owner, overridden, ownerChanged, setInCode, description, cc, bcc and carriesSecret. A stored From or Reply-To that contains {{ or {% is now rendered as Liquid rather than sent as written.
9. Outstanding password-reset links stop working after nextly migrate. Reset and verification links are now bound to the account they were issued for. The migration removes every outstanding password-reset link, and every email-verification link it cannot safely tie to its account (expired ones, ones for an address no account holds, and ones whose account was created or changed after the link was issued). Anyone holding one requests a new link.
10. Code-defined access rules: write permissions as action-resource. Every access rule (collection, Single and field rules) now gets the same context, and its permission list spells each permission the way it is stored (update-posts). For this release the list also carries the old resource:action spelling (posts:update), so a rule that checks posts:update keeps working but logs a warning naming the spelling to use; a later release drops the old spelling. A rule that reads the list's order or length now sees the stored spellings first. In TypeScript, AccessRuleContext (and its alias AccessControlContext) now requires req, target and locales, so a context you build by hand, as in a unit test of a rule, must set them. Rules are now told the languages they judge (locale and locales); to restrict by language, check locales, since locale is absent for an operation on every language. A project made from the blog template before this release still has the old check in has-permission.ts: change ${collection}:${operation} to ${operation}-${collection}.
11. Password rules, and custom password hashers. A new staff password must now be at least 8 characters (set auth.minPasswordLength, 8 to 15, for more), counted as characters, not code units; must not be on a list of 3,000 common passwords (compared without regard to case); and must fit what the hasher reads: 72 UTF-8 bytes for the default bcrypt. The old rule's required mix of character kinds is gone. Some passwords the old rule accepted are now refused when set, such as a common password with symbols swapped in, or a long passphrase over 72 bytes. Existing passwords keep signing in; only new ones are judged. If your app passes its own passwordHasher, give it a reach (how many bytes it reads) or a validate function before upgrading: without one, the server refuses to start and names the hasher.
12. MySQL: a request no longer waits without end for a database connection. When every connection in the pool is busy, a request now fails after pool.connectionTimeoutMs (15 seconds by default; set 0 to wait without limit as before, or raise it). On SQLite, a transaction queued behind others gives up after the same time. The request answers 503 DATABASE_BUSY with Retry-After: 5, entry reads and writes included (a few paths still answer 500; the changelog names them). A collection or Single write, or a registration, that saved before a later step waited answers that the change was saved, without a retry hint. A locked SQLite file (SQLITE_BUSY) answers DATABASE_BUSY instead of 409 CONFLICT.
13. Code-defined component validators now run. A component field's own validate, from fieldGroups in your config, now runs when a write carries that component: at the top level of a collection or Single, inside a group or repeater, or inside a component held in one, and in a group or repeater inside another component. A component placed directly in another component's fields is validated only where it is stored (see Known limitations). Content that fails it is refused when a save or publish next carries it, and a held draft saved before this release is judged in full when it is published. A Single with drafts on, whose only localized component is reached through virtual fields, used to save live; its saves without a status are now held as drafts.
14. Direct API release writes that act for an API key must name the key. A release write (create, addMember, removeMember, changeMemberAction, schedule, cancel, publishNow, update, delete) that passes an API key's authenticatedScope must now pass the key's apiKeyId on the same call, so the release's history can name the key; without it the write is refused with INVALID_INPUT before anything is written. REST requests already carry it, and calls without an API key's scope are unchanged. Release history is experimental.
15. Plugin migration lists must be in name order, and a plugin's applied migrations must stay as they ran. nextly migrate now checks every plugin's migration history before it changes anything. A plugin that lists its migrations in an order other than their names now stops the app from starting, not only nextly migrate; lists written by nextly migrate:create --plugin are already in order, so only a hand-edited list is affected. A migration the database records as applied must not be edited, removed or renamed, and a new one must sort after the applied ones. To undo a migration rolled back by hand from the middle of a plugin's history, roll back the migrations after it with nextly migrate:down --plugin, then run nextly migrate.
16. The schema guard reads more of a migration, and refuses some hand-written shapes it used to run. It judges each app and plugin migration file before it runs (migrations already applied are not run again, so they are unaffected). Newly refused, with nothing applied: on PostgreSQL, a statement that names a table or index after an earlier statement in the same file dropped or renamed it, such as DROP TABLE IF EXISTS t; CREATE TABLE IF NOT EXISTS t (...); ALTER TABLE t ... (move the later statement to a migration of its own), ALTER TYPE ... CASCADE, and an index named after a SET search_path or CREATE SCHEMA; on MySQL, ALTER TABLE ... ADD name type without the COLUMN keyword on a table another owner has an element on, including core's tables (write ADD COLUMN). Migrations written by nextly migrate:create are not affected.
17. MySQL: migrations run in strict mode, and a column is narrowed only when its values fit. nextly migrate (and migrate:down, migrate:resolve and plugin migrations) runs each migration on MySQL with STRICT_ALL_TABLES, so a statement that would cut a value short or coerce it now fails where a server without strict mode truncated it with a warning; schema statements before it in the same file stay applied. A hand-written migration that relied on that coercion needs fixing first. Narrowing a text column to varchar(N) (by db:sync, the dev reload or an admin schema save) now counts the stored values longer than N first and stops, naming the table, column and row count, when there are some: on an older MySQL install whose Single or field-group tables hold a text field as TEXT, shorten values over 255 characters or give the field a type that holds them.
18. Role changes through the REST API answer differently. Only a super-admin can assign the super-admin role, or a role that inherits it, through the REST API, and an API key cannot, even a super-admin's. POST /api/roles with isSystem: true now answers 400. POST /api/users/:id/roles now answers with what happened instead of always 200: 404 for a user or role that doesn't exist, 409 for a role already held, 400 for an expiresAt that isn't an ISO date and time, and 403 when the caller may not assign that role. A client that sent a date without a time, or ignored the answer, needs updating.
19. An access key that doesn't apply now stops the app from starting. A collection's access accepts create, read, update, delete, publish and unpublish; a Single's accepts read, update, publish and unpublish (a Single has no create or delete); a field's accepts create, read and update. Any other key, such as a misspelt reed or a Single's delete, used to be accepted and govern nothing; now defineCollection, defineSingle and defineFieldGroup (and nextly build) refuse it by name, so an app that defines it won't start. A field's access must also be an object whose rules are functions or booleans. Remove or correct such keys before upgrading. Every rule may now also be written as true or false, and a field rule written as false withholds the field from every checked read and write (REST and admin requests), as a function returning false does.
20. Media writes are checked when the caller asks to be checked. A Direct API media write (upload, update, delete, bulkDelete, folders.create) on an instance or call with overrideAccess: false now needs create-media, update-media or delete-media, as the REST media routes do, and one that names no user is refused. A plugin's ctx.services.media write given a request context with a user is now checked as that user, whatever the context's overrideAccess says, where every plugin media write used to run trusted: pass { as: "system", user } to keep it trusted and attributed to that user. Under { as: "system", user }, collection hooks now receive that user, with no roles, where they received no user; a hook that branches on whether a user is present may act differently. A trusted Direct API media write on an instance made with an API key is now recorded as the key, not its owner.
21. Upgrade every instance together, and don't roll back to alpha.68 once alpha.69 has started. On its first start, alpha.69 rewrites the default email layout and the built-in password reset, verification and welcome emails your site hasn't edited, so they read the new email brand settings. An alpha.68 instance on the same database renders the reset, verification and welcome buttons without a background.
22. A plugin's history autosave is stored as REST stores one. ctx.services.versions.autosave now leaves password values out of the stored snapshot (rows of a list included), whoever the call is made as, and is refused with an internal error when the collection or Single it names doesn't exist or its fields can't be read, where it used to store the snapshot as given. setLabel, deleteWorkingDraft and autosave also take an optional ServiceOpts: pass the person the plugin acts for to have the write judged as REST judges them.
23. The REST email send routes are removed. POST /admin/api/email/send and POST /admin/api/email/send-with-template are gone, with their package exports nextly/api/email-send and nextly/api/email-send-template. Send from server code with nextly.email.send() or nextly.email.sendWithTemplate(); to send from a browser or another service, add a route of your own that checks who may send, and to whom, before it calls them. The admin's Send test on an email template now sends the saved template only to the signed-in user's own address, needs the update-email-templates permission, and refuses API keys.
24. Direct API reads of users, roles, media and field groups are checked when the call asks to be. With overrideAccess: false, on the instance or on the call, users, roles, permissions, media and fieldGroups reads now ask the grant REST asks for the same read (read-users, read-roles or read-permissions, read-media; field groups need a named user), and a checked read that names no user is refused. Before, these reads ran unchecked even with overrideAccess: false. A refused read throws, even with disableErrors. Calls left trusted, the default, are unchanged.
25. Translated documents: publishing a language no longer puts back a newer shared value, and pending changes begun before this release may be refused. A language's publish is now refused with a 409 (shared-fields-out-of-date) when a shared field has been published by someone else since its pending change began; taking the live values (POST …/versions/working-draft/take-live?locale=, or "Take the live values" in the admin) brings it up to date. A pending change begun before you upgrade has no recorded starting point, so its publish is refused whenever a shared field it holds differs from the live value, its own edits included. Taking the live values replaces those edits in the fields you may write (a shared part of a translated component is copied by hand): re-enter them, save, and publish, or publish such changes before upgrading. A scheduled release holding one is refused for that document when it runs. Publishing every language of a Single at once can also be refused (shared-edits-unordered, shared-edits-unmergeable); publish one language at a time.
26. Node 22.12 or later. Every package now requires Node ^22.12.0 or >=24.0.0, and Node 24 LTS is recommended; Node 20 is no longer supported. Upgrade Node before you upgrade Nextly.
27. Next.js 16.4.1 or later (or 15.5.28 or later), and sharp 0.35.5 or later. Every Nextly package that peers on next now asks for ^15.5.28 || ^16.4.1, and nextly's optional sharp peer for ^0.35.5; earlier versions carry critical advisories, including unauthenticated remote code execution. Next.js 14 is no longer supported. Upgrade both in the same step: pnpm add next@latest sharp@latest, or your package manager's equivalent. With npm, an older next stops the install with a peer conflict. React is unchanged.
28. Date filters take an instant with its offset, or a calendar date. A filter on createdAt, updatedAt or a date field must be an ISO 8601 instant with its offset (2026-09-01T09:00:00Z) or a date (2026-09-01, the start of that day in UTC). Any other value, including a time with no offset, is refused with 400 (FILTER_INVALID_OPERAND), where PostgreSQL and MySQL used to pass it to the database.
29. A localization fallback list that loops no longer boots. If the fallbackLocale lists lead from a language back to itself (fr → es → fr), the app refuses to start and names the loop. Remove one of the codes it names from the list that leads back.
30. A code access rule allows only by returning true. A rule that returns null, undefined, a string or any other value now refuses, as false does, for every caller and at every way in. Before, a visitor was refused only by false, and a signed-in user was let in by any truthy value. Make every rule return true to allow. For this release, each request refused this way that a rule allowed before is logged once, naming the collection or Single, the action and the kind of caller. ctx.caller.can and nextly.access.check now return a boolean.
Known limitations
A component placed directly inside another component's fields is stored only where that outer component is held in a named group or a repeater. There it is saved with the outer component's value, shown in the admin and validated. Where the outer component is a field of its own (at the top level of a collection or Single, or under rows, tabs, collapsibles or an unnamed group), it is not stored: the admin doesn't show it, it isn't required, and a save, draft save or publish that carries a value for it is refused with NOT_STORED (an empty value passes). To store it everywhere, put it in a group or repeater inside the outer component.
Rename one translated field at a time. A translated field's values move with it when it is renamed in a save that removes only that field and adds only its new name, with a compatible type. A Schema Builder save that removes a translated field while it adds another field (several renames in one save, a rename beside an added field, a rename to an incompatible type) is now refused before anything changes, with each such field named (TRANSLATED_FIELD_REMOVED_BESIDE_ADDED) and the two saves that work: rename or remove one translated field and save, then add or change the others. The same goes for a collection's or Single's settings save that turns translation on (turn it on with the fields as they are, then rename or add), and for a field group's settings save that removes a translated field while it adds a nested field group. A schema dialog's explicit "drop and add" answer, offered only for a single removed and added pair, still drops the old field's values in every language. A rename made in code and applied by nextly db:sync or a dev reload is not refused yet: the old translations column is kept, with its values, and the renamed field starts empty.
Security
An access check whose role or permission read fails now refuses the request (DATABASE_BUSY when the database was busy, otherwise an internal error) instead of judging the caller as holding no roles or permissions. This covers code-defined collection, Single and field rules; a field rule whose caller's grants cannot be read is denied. listRoleSlugsForUser and listEffectiveGrants throw a NextlyError when the read fails, where they returned an empty list; a caller with no roles or permissions still gets an empty list.
A Single call made while serving an API key's request is judged as the key, not as the key's owner, as collection calls already were. A hook's Single read or update may now be refused where the key lacks the permission.
Field rules now withhold fields inside field groups on live reads: lists, reads by id, Single reads, populated relations, and create and update responses. Trusted (overrideAccess) reads are unchanged.
Released 21 packages at 0.0.2-alpha.69 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
-
#2365
6093742Thanks @nextly-agent! - A batch save that cannot record its history now keeps the versions it would have pruned. When the history table is missing (an install not yet migrated), a batch update withstopOnError: falseno longer commits a held edit or removes older versions with no record of it: the whole batch rolls back. -
#2581
a0d926aThanks @nextly-agent! - A card's settings sheet keeps a change where the reader is, shows the card beside the form, and reopens on what is stored after Cancel.- Done while customising holds the change with the dashboard's other unsaved changes and closes the sheet. At rest it saves the change at once: the sheet stays open while saving, closes when the save lands, and says why in place when it does not.
- A live preview draws the card as the form's values would, with the dashboard's own renderer, beside the form on a wide screen and above it on a narrow one. A change that alters what the card asks, such as its number of rows, is asked once for the preview alone after the values settle. The card keeps its own answer until Done.
- Cancel now discards the change. The sheet used to reopen on the value the reader cancelled, although nothing had been saved: the form was given the sheet's own starting values rather than a copy, and wrote into them.
- A quick setting whose new answer the card could not load is announced as saved, with the card still showing its earlier data, rather than not announced at all.
-
#2375
eb09f42Thanks @faisal-rx! - An API key is refused while the person who created it is deactivated, and
works again once they are reactivated. The instance that deactivates the
person refuses the key at once, and every other instance within ten seconds,
the bound that already applies to that person's sessions. An administrator
setting the person's password does not stop their keys. -
#2660
792d1d5Thanks @nextly-agent! - A declared widget's card is now held to what its display draws for the card's own settings, not only the starting ones, so a query function that writes a different operation for a setting a reader chose fails that card alone rather than drawing an answer it misreads. And the read decisions a declared widget's data function needs, for collections the batch did not name in advance, now take the same bounded turns as the rest of the batch's, instead of starting all at once. -
#2630
351231cThanks @nextly-agent! - A widget declared withdefineWidgetand drawn by a core display (metric,list,tableand the others) now has its cards answered on the server, as the person asking, and drawn in the admin, in the widget's own words when it has nothing to show. A widget drawn by its own component (display: { component }) is checked at startup but not offered yet: the admin does not yet hand a component the props its declaration types, and development says so once per widget.- A card is asked for by its id, and core builds its data from the widget's declaration: its
datafunction run with the card's settings, the person and the batch's one instant, or its query written from the settings. Only a card the reader was handed is answered; every other card gets the one "unavailable" answer. While the dashboard is being customised, a card is asked with the settings it is drawn with. - Reads are the person's. A data function's
services.countandservices.findread as the person asking, through the same check a card's own query passes, and read nothing once the card has given up. Options a plain JavaScript caller passes beside them (user,as,overrideAccess) are never read. WidgetDataInputgainsnow(@experimental): the batch's one instant, so every card asking for the last 30 days asks for the same days.ResolverOptionsgains the samenow(@experimental).WidgetResultgains{ op: "data", data }, a declared widget's own data for its own component, and a slot may carryempty: true, the verdict of the widget's declared empty test. The admin then draws the declared empty message and action, whose link is only ever a path under the admin.- Definitions gain
serverData,allowedWidths,defaultWidth,allowsSeveral,sketchandempty. Core publishes these for a declared widget.registerWidgetrefusesserverData, and an empty state's action is withheld from a reader without its permission. - Default dashboards never place a declared widget; it is offered in the add panel instead.
- A card is asked for by its id, and core builds its data from the widget's declaration: its
-
#2349
e2afec9Thanks @nextly-agent! - Deleting media no longer removes a file another media item still refers to, however the other item refers to it: as its original, through one of its image sizes, as its thumbnail, or under another spelling of the same path. Two items can share a stored file after an import that copies a record, or on a storage adapter that keys files by their content. A crop keeps such files too, including when its write fails.- When the database cannot be read to check this, nothing is removed and the delete or crop still succeeds; the log names the item whose files were left.
- Failure logs from media cleanup no longer show credential query parameters (
X-Amz-Credential=…,token=…) wherever they appear in a storage error, nor URLs written without a scheme.
-
#2379
077631cThanks @faisal-rx! - Deleting a staff user now erases every email delivery record that names them, under every address they used, and any record written for them afterwards is written erased. Another person at the same address keeps their records. -
#2665
931faf8Thanks @nextly-agent! - A chart's dismissed tooltip stays dismissed until the pointer moves. Escape hides a hovered tooltip on a dashboard chart, but the bar under a pointer that had not moved could bring a tooltip straight back, because hiding the tooltip uncovered that bar and the browser reported it as entered. Now only a move of the pointer, or of the keyboard's point, shows a tooltip again. -
#2451
1462411Thanks @faisal-rx! - An email the provider fails or refuses now says whether sending it again can
help:nextly.emailand a plugin'sctx.services.emailreturnfailurewithretryable, the provider's status where it gave one, and a
short reason, and list any recipients the provider refused. A send refused before
any provider is reached (a bad recipient, a provider named but not found, a missing template) still
throws, as before. Form Builder logs a
notification that was not sent as failed instead of sent, and no longer writes
recipient addresses into its logs. -
#2364
9b80400Thanks @nextly-agent! - Several processes starting Nextly against one fresh database at once, asnext build's workers and a fleet of instances do, now seed each default row once without reporting the ones another process wrote first. The process that lost the race used to log the duplicate as a warning or an error with a stack trace (an email template, a role preset, a plugin's role), report the whole permission step as failed and skip the plugin roles that wait for it, or fail its sync of the code-defined user fields. A row another process wrote is now taken as seeded and logged at debug; any other failure is still reported as before. The same holds for a Single's default document, which its first read creates: concurrent first reads, such as a fresh build's pages, used to fail every read but one with aUNIQUE constraint failederror logged at error level, and each now gets the one document that was stored. On PostgreSQL and SQLite, a permission another process created at the same moment was also reported as created under an id that was never stored, so a grant made for it could point at no permission: the permission is now read back and reported as found. -
#2467
8755798Thanks @faisal-rx! - A send can now carry anidempotencyKey, and the email then goes out once on
every provider: onnextly.email.sendandsendWithTemplate, the same two on a
plugin'sctx.services.email. A repeat of a send
that succeeded returns its result withrepeat: trueand sends nothing; a
repeat after a failure sends again; the same key with a different message is
refused. Keys are scoped to their caller, so two plugins never share one, and
Resend is also handed a key derived from the caller's.Nextly stores a keyed digest of each key in a new
email_send_claimstable and
a newkey_digestcolumn ofemail_deliveries, never the key or an address,
and erasing a person clears the digest from every record of their sends.
nextly migrateand boot add both on all three databases, and rewrite no row.
Sends without a key are unchanged. -
#2441
9937cc5Thanks @faisal-rx! - A document-lock claim is now judged as an update of that document is. Claiming or renewing a lock asks the same question a REST update of the document asks, so an API key must hold the update grant and pass the collection's or Single's ownupdaterule. Releasing a claim still needs the grant alone, since its claim token fences it. Sessions are judged as before. -
#2458
253cd89Thanks @aqib-rx! - A column narrowed tovarchar(N)is judged by the values it holds, andnextly migrateruns in strict mode on MySQL.On MySQL, Single and field-group tables made before their tables came from the schema pipeline hold a
textfield asTEXT, where the schema declaresVARCHAR(255). Everydb:syncand dev reload asked to narrow them in a terminal, and failed without one.db:sync, the dev reload and an admin schema save now count the stored values longer than N before narrowing a string column tovarchar(N). When there are none, the change loses nothing and applies without a prompt. When there are some, the apply stops before any DDL and names the table, the column and the number of rows; shorten those values, or give the field a type that holds them, and apply again.nextly migrate,migrate:down,migrate:resolveand plugin migrations run each migration not marked-- nextly:no-transactionon MySQL withSTRICT_ALL_TABLESadded to the session's mode, and puts the session's mode back afterwards. A file that would cut a value short — such as theMODIFY COLUMN … varchar(255)thatmigrate:createwrites aftermigrate:baselineadopts one of these tables — now fails with "Data too long" at that statement, which changes nothing (schema statements before it in the same file stay applied, as MySQL commits them), where a server without strict mode used to truncate the value with only a warning. A hand-written migration that relied on MySQL's non-strict coercion now fails the same way.- The MySQL adapter's
transaction()takes a newstrictSqlModeoption, which does this for any transaction. PostgreSQL and SQLite ignore it.
-
#2371
d986b28Thanks @nextly-agent! - A past version now reads in the editor's own layout with its changes marked. One comparison bar above it says which version is shown, what it is compared with (the version before it in its language) and how many fields changed, with Previous and Next change. Each changed field is marked with what changed; unchanged fields fold behind "Show N unchanged fields". While a version is shown, Back to current and Restore… replace Save and Publish in the title bar, and on a phone they sit in a bar at the bottom. The banner above a past version is gone.The admin's unused localStorage autosave and draft recovery (
useAutoSave,useDraftRecovery,DraftRecoveryDialog) are removed. Nothing called them since autosave moved to the server, and none was exported from the package. -
#2384
92852f7Thanks @faisal-rx! - A person's standing is read again as soon as the change that deactivates
them, or sets their password, commits, however long that change's
transaction stays open, on every database. -
#2272
ab73264Thanks @nextly-agent! - A project gets the release of Nextly its create-nextly-app belongs to.- A new project declares
nextlyand every@nextlyhq/*package at exactly the CLI's version, from any template, so the templates, the agent guide and the skills match what is installed. npm'slatesttag no longer decides anything, and the registry is not asked. - The blog template is downloaded at the CLI's own release tag instead of
main. If GitHub has no template for that release, the run stops and names--branch; if GitHub cannot be reached, it names--local-template. A branch name with a/now downloads too. - Adding Nextly to an existing app installs its packages at exactly that version, recorded exactly, after the packages they require alongside them, so pnpm with strict peers accepts the install. An app that already has a Nextly package at another version stops before anything is written or installed, and is told what to set: one declared as a range or at another version in any dependency field, or one it loads at another version, from its own
node_modulesor a workspace folder above it. --branchno longer defaults tomain: given, it takes the template from that branch, and the packages from thealphatag, as--local-templatedoes.--use-yalcstill links local builds.
A project made before this release is not changed by it, and keeps the versions it declares. Before adding Nextly to an existing app, set every Nextly package it has to exactly the CLI's version.
- A new project declares
-
#2433
f74d122Thanks @nextly-agent! - A new project created outside a workspace records the pnpm or Yarn version that created it aspackageManager, so Corepack installs with that version instead of its own default.
Adding Nextly to an existing app records it too, but only when the app chooses no package manager yet and isn't part of a workspace. -
#2550
8c4d300Thanks @nextly-agent! - Publishing or unpublishing every language of a collection entry now clears its cached pages even when the request fails after the change is saved, and is reported saved only once a transaction around it has committed. -
#2707
5352b69Thanks @faisal-rx! - A refusal now says what was refused, in the site's own words.A refused request's
error.messagenames the action and the thing as the site names them: "You can't edit this post.", "You can't create posts.", "You can't edit Site Settings.", "You can't see API keys." The thing is a collection's label (one entry of it where the request names one), a Single's label, or a system resource in words. It never names a permission as stored (update-posts), a role, a rule or which check refused, and never repeats a slug the site does not know, so a URL cannot make a refusal echo it. The REST route gate, the Direct API, plugin services and the routes built on them answer this way.Which check refused, and on what grant, is logged on the server at
info([nextly] Refused a request, with the reason and the request id the caller was answered with), and never sent.A request that is not signed in, or whose session has expired, or whose API key is not valid, is answered "Authentication required." in every case; the code still tells an expired session (
TOKEN_EXPIRED) apart, so the admin refreshes it as before.One status changes. The standalone route handlers exported from
nextly/api/*now answer a request presenting an API key while the key service is unavailable with 503SERVICE_UNAVAILABLE, as the main REST handler already did; they answered 403. Every other refusal keeps its status.A client that matched the old wording ("You do not have permission to …", "Access denied: …") reads
error.codeinstead, which is unchanged. -
#2285
6f2d0bdThanks @nextly-agent! - Add a calendar read for releases.GET /api/releases/calendarand
nextly.releases.calendar()take a window of days (fromandto, at most
six weeks) in a named time zone and answer every day of it with the complete
count of its releases by state and its first three releases in time order, so
a calendar's "+N more" is always true. Days follow the zone's own clock,
daylight saving included: a release at 23:30 in New York is on the next day in
London. The list gains a day filter (dayandtimezoneover REST,
day: { date, timezone }from the Direct API) that returns one such day in
full, paged, in the same order. A published release is on the day it went out,
any other release on the day of its scheduled time. Both are experimental.The releases table gains two indexes, on
scheduled_atand onpublished_at,
which the calendar and the day filter read by.nextly migrateadds them to
an existing database. Index only, no data change. -
#2562
7183036Thanks @nextly-agent! - A renamed collection or Single keeps its history, who may use it and where its webhooks go: its versions, pins, "last looked at" markers, content events, release membership and each release's history of its documents, schema events, activity, archived translations, permissions (with every role's grants of them), live webhook filters, webhook events not yet sent and the webhook recording choice move to the new slug in the same transaction as the rename, instead of staying behind for the next entity under the old slug. A renamed Single's document reads its new slug, and content cached under either slug is refreshed. A rename onto a slug that still holds an earlier entity's records, or onto one longer than 50 characters, is refused, and document locks, preview links and kept permission answers end with the old slug, as do any an earlier entity left under the new one. -
#2443
30d4944Thanks @nextly-agent! - A request now waits a bounded time for a database connection on every dialect, and past the bound fails instead of waiting without end, as "database busy" where the error reaches the request boundary (see "Where it shows" below).- The setting:
pool.connectionTimeoutMs, default 15 seconds (20 on Neon, from the PostgreSQL adapter's provider defaults), with the same meaning on PostgreSQL, MySQL and SQLite.0waits without limit. - Where it shows: route handlers, server actions, sign-in and the REST dispatcher answer
DATABASE_BUSY. Collection and Single entry reads and writes answerDATABASE_BUSYtoo. A few entry paths still answer 500INTERNAL_ERRORwhen it is busy: publishing every language of a Single, duplicating an entry, and a read whose locale, component or media step is the one that waits. - The error:
DATABASE_BUSY, status 503, withdata.retryAfterSecondsof 5 and aRetry-After: 5header on REST. The statement or transaction that waited did not run. Anything the request committed before it stays committed, so a request that writes in several steps is safe to repeat only where those steps are. A collection or Single write, or a registration, that committed and then hit a busy database in a later step answers that the change was saved, without a retry hint. - PostgreSQL: the timing is unchanged; the pool already gave up after 15 seconds. What changes is the answer: it was a 500
INTERNAL_ERROR, and is nowDATABASE_BUSY. - MySQL (behaviour change): a request that found every connection in use used to wait in mysql2's queue without limit. It now fails after
pool.connectionTimeoutMs, unless you raise it. The time a new connection gets to open (connectTimeout) also moves from 10 to 15 seconds, so it matches PostgreSQL; a connection that cannot open in time is still a connection error, notDATABASE_BUSY. - SQLite: a transaction waits at most
pool.connectionTimeoutMsfor the one connection behind the transactions ahead of it. A write blocked by another process holding the database file pastbusyTimeout(SQLITE_BUSY) is nowDATABASE_BUSYtoo, where it was a 409CONFLICT.SQLITE_LOCKEDstays aCONFLICT. - Retry-After: it is now set for any error carrying
data.retryAfterSeconds, not onlyRATE_LIMITED. - For adapter authors: the adapters raise the busy error with the code
NEXTLY_POOL_WAIT_TIMEOUT, exported from@nextlyhq/adapter-drizzle/typesasPOOL_WAIT_TIMEOUT_CODE, withpoolWaitTimeoutError()to raise it.
See When the database is busy in the production guide.
- The setting:
-
#2107
1b96481Thanks @faisal-rx! - Password-reset and email-verification links now act only on the account they
were issued for, and only while it still holds the address they were sent to.
Each link records its account (a newuser_idcolumn on
password_reset_tokensandemail_verification_tokens, with a foreign key to
users). Changing an account's address revokes its links, so one sent to the
old address stays dead even after the account moves back. Deleting an account
removes its links, whatever address they went to. Someone later given an
address is never reset or verified by a link sent to an earlier holder.Issuing a link, following one, changing an address and deleting an account
each hold the account's row for their transaction, so a link issued or
followed while the account moves or is deleted is either revoked afterwards
or refused. Following a link now checks it, applies it and spends it in one
transaction, so two redemptions of one reset link cannot both set a password.nextly migrateadds the column and removes every outstanding password reset
link: a reset link may have been given any lifetime, and its stored times
cannot show reliably when it was issued. It ties each outstanding email
verification link to the account holding its address when that account was
created and last changed before the link was issued, so those links keep
working. A verification link whose account was created or changed at or after the
time it was issued (on MySQL and SQLite, which store whole seconds, also in the
same second) is removed, since it may have been sent to an
earlier holder of the address, and so are expired links and links to an
address no account holds. Their owners request a new link. -
#2403
2f498a6Thanks @nextly-agent! - Editing a role (its name, its permissions or its child roles) works on SQLite. The edit ran in a transaction the SQLite driver refuses for asynchronous work, so it failed there with a server error before changing anything, while PostgreSQL and MySQL were unaffected. -
#2496
837c7b1Thanks @nextly-agent! - A collection or Single write, or a registration, that saved and then failed answers that the change was saved, without a retry hint, and the admin retries a failed write only when the server refused it before running it, so a saved change is no longer made twice. -
#2594
1ebd31bThanks @nextly-agent! - Looking up a collection's schema no longer writes to the console. Every query against a collection resolved its schema through a lookup that logged three lines, one of them the name of every registered collection, so a busy app wrote thousands of identical lines to its server log per burst of requests. A lookup that has to build the schema, or finds none, still logs as before. -
#2288
934cef1Thanks @nextly-agent! - Unsaved work gets a recovery point after a slow save. A save slower than the
autosave debounce let the edit's recording fire while recording was off, where
it was refused, and nothing asked again; when recording comes back on and the
editor still holds unsaved changes, a recording is now asked for again. -
#2359
ea23a3bThanks @nextly-agent! - A value a unique custom user field already holds for another user is now refused with a conflict (DUPLICATE), when creating or updating a user, and a refused update changes nothing: the account's other fields, its roles and its custom fields are written together or not at all. Before, creating such a user took the refusal for a missing table: it created the user without any custom fields and stopped writing custom fields for the rest of the process, and an update logged it and carried on. On MySQL, a unique field of a plugin type stored as text with nomaxLengthis now aVARCHAR(255)column carrying its unique index, as a built-in text field is, rather than a TEXT column (which MySQL cannot index without a key length) that stoppednextly migrate. A value longer than such a column holds is refused, naming the field, before it is written: before, it failed at the database and was taken for a missing table. An install where that column is already TEXT keeps it, with every value whole:nextly migratestops, naming the field, until the column is changed toVARCHAR(255)oruniqueis removed. A plugin type stored as long text or JSON cannot be unique: the config is refused at the field, as a textarea's is, including when the plugin declaring the type is in the same config. -
#2681
b26f715Thanks @faisal-rx! - A user's roles and permissions are now read once and kept in memory until a role or permission changes, in place of the separate per-answer caches. Every change that affects access retires what was kept, in every instance sharing the database, so the next request reads afresh: editing a role's permissions, assigning or removing a role, changing role inheritance, deleting a role, deactivating or deleting a user, and a role assignment expiring. A request that asks about a user again reads only the role version, and a request that asks several permissions reads the user's roles once.Permission answers are no longer stored in the
user_permission_cachetable, and theuserPermissionCacheschema export is deprecated; the table is removed in a later release.PERMISSION_CACHE_ENABLED,PERMISSION_CACHE_TTL_SECONDSandPERMISSION_CACHE_MEMORY_SIZEno longer change how access is cached, and a warning says so when one is set.PERMISSION_CACHE_ENABLED=falsestill tells Nextly that no instance reads the stored answers, so they are not emptied when access changes. -
#2429
7afab03Thanks @faisal-rx! - A Direct API call made as a user (overrideAccess: false), a plugin service call madeas: "user", and a page render now each look up the caller's roles and permissions once, however many rows, field rules and permission checks they read. That covers every Direct API method and namespace method, and both collection and Single routes, including the reads theirrenderandbuildMetadatamake. A 50-entry list read through the Direct API or a plugin service costs the same access queries as a 1-entry list. A call made inside a request shares that request's lookups. -
#2420
fa80fb9Thanks @faisal-rx! - A request now looks up its caller's roles, permissions and super-admin authority once, however many gates, rules and rows ask about them. A list read whose collection has field-level read rules no longer repeats those lookups for every row it returns; a 50-entry list costs the same access queries as a 1-entry list. Any role or permission change the request makes is seen by its later checks. Work outside a request, such as a job, looks the caller up as before. -
#2471
6b5d7e1Thanks @faisal-rx! - Every access rule may betrue,falseor a function: a collection'screate,read,update,delete,publishandunpublish; a Single'sread,update,publishandunpublish; and a field'screate,readandupdate, in a collection, a Single or a field group. On a collection's or a Single's own fields, a field rule written asfalsenow withholds the field from every checked read, and keeps it from being written by a checked write, like a function returningfalse. A checked call is one made withoverrideAccess: false, as every REST and admin request is. Trusted server code, the Direct API's defaultoverrideAccess: true, skips field rules, whether they are written as booleans or as functions.Breaking at startup: an
accesskey that its kind doesn't have now stops an app from starting when the collection, Single or field group is defined withdefineCollection,defineSingleordefineFieldGroup, and failsnextly build. Entities written as plain objects, and those the Schema Builder stores, are not checked. Before, such a key was accepted in silence and governed nothing: the operation it meant to rule fell back to stored permissions.The keys each kind accepts:
- A collection:
create,read,update,delete,publish,unpublish. - A Single:
read,update,publish,unpublish. A Single has nocreateordelete, so a Single carrying either now fails. - A field, in a collection, a Single or a field group:
create,read,update.
Any other key, such as a misspelt
reedor a Single'sdelete, is refused when the collection, Single or field group is defined, withACCESS_KEY_UNKNOWNnaming the key, where it sits, and the keys that kind accepts. To fix it, remove the key, or correct its spelling if it was meant to be one of the keys above. A field'saccessmust also be an object whose rules are functions or booleans; any other value is refused at definition in the same way. - A collection:
-
#2456
73498e1Thanks @faisal-rx! - Every access rule is told the languages it judges.A collection's, a Single's and a field's rule now get
locale, the one
language the operation is in, andlocales, every language it involves:- a read or a write in one language gets that language; a read with an
unknown or missing?locale=is judged as the default it resolves to, and
a write naming an unknown language is judged as the default and then
refused with 400; a read's
localesalso lists each language a missing value falls back to, in the
order it tries them; a bulk or transactional create or update writes the
default language, and gets it; a duplicate gets the default language and
each it falls back to, since it copies what a read of the source shows; - a read that also reads each language's translation state
(?translation-status=1, or a_translatedfilter) gets every configured
language, keeping its own aslocale, and so does an autosave, or a
working-draft discard, naming a language the site does not configure; a
bulk update by query, whose filter may choose rows by any language, gets
every configured language too; - a read of every translation (
locale=all), a write to every language
(locale=*), and an operation on the whole entry (a delete, a bulk
delete, publishing or unpublishing every language, a release)
get every configured language, with no singlelocale; - content a collection or Single stores the same in every language (it is
not localized and has no localized field group) gets every configured
language, with no singlelocale, whatever?locale=names; - a site with no languages gets none.
So a rule can answer by language, such as
update: ({ locales }) => !locales.includes("fr"), and a rule that limits
someone to some languages refuses an operation that reaches beyond them.
Write language rules as restrictions like that one, not as admissions by
inclusion (locales.includes("fr")): where the languages an operation
involves can't be known exactly, such as an operation that names none or a
collection built in the admin, the rule is told every configured language,
which a restriction refuses and an admission would allow. Restrict with
locales, notlocale:localeis absent for an operation on every
language, and leaves out the languages a read falls back to.The REST route gate is told the same languages as the service gate, from the
request's?locale=for an operation on one language's content (a history
list filtered to one language among them), the default for a bulk write, and
every language for any other. It reads them from the parameters the request
is dispatched with, so a language named twice is judged as dispatched.A collection preview link names no language, so minting or opening one asks
the read rule in every language: a collection whose read rule closes any
language can't mint entry previews until the preview path binds the link's
language.A rule that does not read
localeorlocalesanswers as before. Code that
builds a rule's context by hand, such as a unit test of a rule, now gives
localestoo:[]where the site has no languages. - a read or a write in one language gets that language; a read with an
-
#2169
121d24dThanks @nextly-agent! - The PostgreSQL and MySQL adapters can now tell a test harness when a statement
escapes an open transaction: a query or connection taken from the pool while
the same flow's transaction already holds its own connection, which runs
outside that transaction and is kept when it rolls back. Nextly's own
integration suites fail on such a statement. Nothing changes in a running
application; with no harness listening, the check is a single property read. -
#2053
be9b841Thanks @muzzamil-rx! - The admin's build-mode comment names what itsNODE_ENVfold decides — the
error screens' technical-details box, the caught error's message and stack
trace, stays out of what production users are served — and leaves whether the
development reload stream opens to the running server's answer. The
route-table comment keeps the rule and the runtime answers without the record
of what an older published bundle did. The settings-sync component's
explanation of its sibling shape is stated once, on the component, where the
layout points at it, and the component's file is named after what it exports. -
#2667
8081ddeThanks @nextly-agent! -@nextlyhq/adminbuilds its declarations with oxc, from the type annotations in its own source. Its published types and JavaScript are unchanged. -
#2651
0ab320cThanks @nextly-agent! -@nextlyhq/admin's feature components declare their own types: each export states what it returns and accepts, so its published declarations no longer depend on inference. What the components render and accept is unchanged. -
#2688
57c99aaThanks @nextly-agent! - The dashboard a new reader is given is now the agreed one: Needs attention, Create, Recently edited, Recent activity, Content, Upcoming releases and Team, in that order, each at its agreed width and offered only within its agreed widths.- Each card only when it has something to show. Create needs a collection the reader may create in; Needs attention needs something it counts that the reader may read; Upcoming releases needs a release the reader may read; Content needs a collection or a Single the reader may see. Four new widget conditions say so:
create:available,attention:readable,releases:presentandcontent:present. Each condition's reads happen at most once per request. - One Content card,
core/content, lists both collections (with their entries and drafts) and Singles. It replaces the Collections and Singles cards. A saved Collections, Singles or per-Single card becomes Content, at the first one's place, and the per-Single cards are no longer offered. system:contentanswers Singles too, after the collections, each row saying itskind.- Saved dashboards convert through one retirement map, which now also covers ids minted per Single (
single/<slug>-status). - New,
@experimental:ReleasesService.anyReadable, whether a release exists that a caller may read, one row read through the list's own rules.
- Each card only when it has something to show. Create needs a collection the reader may create in; Needs attention needs something it counts that the reader may read; Upcoming releases needs a release the reader may read; Content needs a collection or a Single the reader may see. Four new widget conditions say so:
-
#2486
19a3503Thanks @nextly-agent! - An access check whose role or permission read fails now refuses, instead of judging the caller as holding no roles or permissions.- Code-defined access rules (collection and Single
access, and field-levelaccess) are handed the caller's roles and permissions. When those could not be read, a rule was handed empty lists. It is now not run: the request is refused withDATABASE_BUSY(503, retryable) when the read waited outpool.connectionTimeoutMs, and an internal error otherwise. A field rule whose caller's grants cannot be read is denied. listRoleSlugsForUserandlistEffectiveGrantsthrow aNextlyErrorwhen the read fails, where they returned an empty list. A caller with no roles or permissions still gets an empty list.
- Code-defined access rules (collection and Single
-
#2729
15256dcThanks @mobeenabdullah! - A request with an API key on a server that has just started no longer answers 503; it waits for Nextly to start, as a signed-in request does. -
#2425
39d7239Thanks @faisal-rx! - Saving an entry or a Single in the admin keeps a related item or upload inside a field group or a dynamic zone that the editor may not read, or that no longer exists. The editor sees it as a "Restricted or missing item" with its id, which can be removed but not opened, as at the top level; before, a save removed such links. A read atdepth=0now returns a to-many relationship inside a field group as an array of ids, as for every other field, rather than as its stored JSON text when the field's name is camelCase. -
#2389
608e1f0Thanks @faisal-rx! - Saving an entry or a Single in the admin keeps a related item or upload the editor may not read, or one that no longer exists. The edit page shows it as a "Restricted or missing item" with its id, which can be removed but not opened; before, a save removed such links. Populated reads are unchanged. -
#2556
ebec922Thanks @faisal-rx! - Code and plugins can now queue an email (@experimental):
nextly.email.queueandqueueWithTemplate, and the same two on a plugin's
ctx.services.email, take every optionsendandsendWithTemplatetake.
A queued email is stored, sent right away, and retried through the job queue
when the provider fails temporarily, with backoff for up to about three hours
(15 attempts); a failure that is not temporary fails it at once. Every attempt
sends with the caller'sidempotencyKey, or with a key of the queued email's
own, so a retry after a lost response is not delivered twice where the provider
takes a key. A template is rendered at each attempt.A plugin can pass its transaction as
tx: the email is then stored in that
transaction, exists only if it commits, and is attempted once it has. An email
stored but never attempted, because the process stopped, is sent the next time
the jobs trigger (/api/jobs/run) runs, and the server log warns at startup
and on each queued send when a queued email is more than 15 minutes past due
because the trigger isn't running.The message is held in a new
email_queuetable, sealed underNEXTLY_SECRET;
the job carries only its id.nextly migrateand boot add the table on all
three databases. Sends that are not queued are unchanged. -
#2102
4ab4263Thanks @faisal-rx! - An email sent to severaltoaddresses now reaches every one of them, through
nextly.emailand a plugin'sctx.services.email;
before, only the first address received it.fromand a newreplyTotake
effect on every way of sending. -
#2353
c271a3fThanks @nextly-agent! - Sign-in and the other auth routes, for a client whose address cannot be resolved, now count a request body larger than the limiter reads among the bodies being read until its handler has finished with it. The limiter stops reading such a body early and the handler reads the rest, which the client could hold open, so these bodies escaped the bound on how many are read at once. A request the limit refuses still gives its place back at once. -
#2355
4a357edThanks @nextly-agent! -nextly plugins installandnextly plugins uninstallwait for the event
handlers a plugin'sonInstalloronUninstallset off, and now for the
follow-up events those handlers emit in turn, before shutting the runtime
down. A producer a plugin started ininit()is not waited for, so it never
holds the command open.When that work is still running after 30 seconds, the command now stops
instead of carrying on. An uninstall stops before any DOWN migration drops the
plugin's tables, which that work may still be reading, and the plugin stays
uninstalling, so running the command again finishes it. Before, it warned
and dropped the tables anyway. -
#2338
07211e7Thanks @nextly-agent! -nextly plugins installandnextly plugins uninstallnow let the event
handlers a plugin'sonInstalloronUninstallset off finish before they
shut the runtime down. Emitting an event doesn't wait for its handlers, so
another plugin's handler (removing what it kept about each account an
uninstall reports deleted, for instance) could be stopped part way. The
uninstall's DOWN migrations then dropped the tables that handler was still
working through, leaving its cleanup unfinished. -
#2636
3c65a6fThanks @nextly-agent! -@nextlyhq/blocks-enginebuilds its declarations with oxc underisolatedDeclarations, from the type annotations in its own source. The published types and JavaScript are unchanged. -
#2638
c56257aThanks @nextly-agent! -@nextlyhq/blocks-reactbuilds its declarations with oxc underisolatedDeclarations, from the type annotations in its own source. The published types and JavaScript are unchanged. -
#2662
71aadd1Thanks @nextly-agent! - A tenant's brand colours no longer make a dashboard chart unreadable. Where branding sets the chart colours, the admin checks each against the card, a bar's track and the tooltip, in light and in dark, and moves the lightness of one below 3:1 until it reads, keeping the brand's hue and never refusing it. The chart colours are now written per mode.@nextlyhq/ui/colorgainsreadableOn(@experimental), the adjustment itself, with the target ratio as a parameter. -
#2676
9b73b41Thanks @nextly-agent! - The adjusted brand chart colours now reach the chart. The server-rendered branding stylesheet sits later on the page and wrote the unadjusted colours at the same specificity, so it won; the admin's per-mode chart rules now outrank it. The content status widget draws its Published segment and dot in the brand's chart slot, held to 3:1 on the card, rather than in the brand's primary as given. -
#2604
f7652e0Thanks @nextly-agent! -@nextlyhq/admin,@nextlyhq/uiand@nextlyhq/builderbuild with tsdown, which completes the move from tsup. What they publish is unchanged: the same entry points, exports conditions and declarations. The plugin template thatcreate-nextly-appscaffolds now builds with tsdown too, through atsdown.config.tsin place of the tsup config. A plugin project now declaresengines.nodeas^22.18.0 || ^24.11.0 || >=26.0.0, where both tsdown and Vitest run, andcreate-nextly-app --template pluginrefuses to scaffold on an older Node, with a message naming the floor. -
#2627
f0e0e52Thanks @nextly-agent! -@nextlyhq/builderbuilds its declarations with oxc underisolatedDeclarations, from the type annotations in its own source. The published types and JavaScript are unchanged. -
#2334
85a2094Thanks @nextly-agent! - A translated field named with capitals in a Schema Builder collection (subTitle) takes writes in every language straight after the collection is created or the field is added. The Builder stores field names lowercased, and the database's columns are named from the stored name, but the tables the running process reads and writes through were built from the name as typed, so every write in a language other than the default one failed with an unknown column until a restart. They are now built from the stored names, as a restart builds them. Nothing stored was wrong, so existing installs need no change. -
#2564
a477920Thanks @nextly-agent! - Reading an entry or a page of entries now makes fewer round trips to the database. A read looked up its collection's registry row several times, each a query of its own. The row is now read once and reused for up to a second, so a read of one entry sends 4 statements instead of 11, and a page of entries 5 instead of 13. That is a few milliseconds off every read on PostgreSQL and MySQL.Reads return exactly what they did before. A change to a collection made in this process is seen at once. Changes to a collection made elsewhere (another instance,
nextly migrate, a direct database write) reach every instance within a second. A read inside a transaction always goes to the database. -
#2626
f87bbafThanks @nextly-agent! - Nextly does not support Next.js Cache Components yet, so it keeps it off.create-next-app16.4 turns Cache Components on by default (cacheComponents: truewithpartialPrefetching: true).- A project
create-nextly-appcreates setscacheComponents: falsein itsnext.config, with a comment saying why. - When
create-nextly-appadds Nextly to an existing app, it turns Cache Components off in that app's config and removespartialPrefetching, which Next.js accepts only with Cache Components on. Its report says what it changed and why. A setting it cannot read safely is left as it was, with what to change by hand. withNextly()and Nextly's start print a warning when Cache Components is on, with how to turn it off. They don't refuse: an app with it on is untested with Nextly, not known to be broken. The start check reads the setting from the app'sinstrumentation.ts, which now passescacheComponents: process.env.__NEXT_CACHE_COMPONENTStocreateRegister;createRegistertakes it as an optional second argument.
Why, and the plan to support Cache Components before Next.js 17: https://nextlyhq.com/docs/getting-started/installation#cache-components
- A project
-
#2545
6fe78dbThanks @faisal-rx! - A plugin route'sctx.caller.can(), the held permission that decides a dashboard widget'srequiredPermissionand a document lock's release, and the schema builder's collection list now ask the one access decision. Every answer they give is unchanged: where one still answers differently from the decision, it keeps its answer through a named difference, so a later change can remove each one on its own.The schema builder's collection list now reads the caller's access once for the whole list, rather than once for each collection.
-
#2483
3e60c59Thanks @nextly-agent! - A card's quick setting (aselectorcheckboxits widget marksquick) is drawn in its header and offered in a new card menu at rest, and a change there is saved at once for the reader. Only that card is asked again, and the change is announced once the card shows it. Settings saved outside customise mode are now kept for a reader who has never arranged their dashboard, who goes on following the default. Changing one card's settings no longer reloads every card on the dashboard. -
#2434
17ac818Thanks @nextly-agent! - A dashboard card's settings sheet can now draw Collection, Filter, Sort and Group by settings, offering only the collections and fields the reader may read, for widgets whose card query is built from them. Registered widgets cannot declare these settings yet: a registered widget's query applies onlylimit, so registration refuses them, as before. A stored choice the reader can no longer make opens at its default, with a line saying so, and "Reset to defaults" leaves a card's Collection as it is. A widget may mark one setting as quick; the mark is validated now, and the card header that shows it comes later. The workspace payload gainswidgetSources, and widget source fields gain an optionalgroupable: falsemark. New experimental type exports onnextly/config:AnyWidgetSetting,ContentSettingandPublishedContentSource. -
#2478
04fd52fThanks @nextly-agent! - A dashboard chart is reachable by keyboard and pointer. It takes one tab stop; the arrow keys move through its bars or intervals, a polite live region reads the current point, and a tooltip shows it. Escape dismisses the tooltip wherever focus is, and a tooltip stays while the pointer is over it. -
#2455
0bae677Thanks @faisal-rx! - The collection access gate now asks the one access decision, keeping every answer it gives today. Where today's gate still differs from that decision, a named deviation keeps today's answer, so nothing a caller is allowed or refused changes. -
#2656
2347689Thanks @nextly-agent! -@nextlyhq/plugin-commerceand@nextlyhq/plugin-accountsbuild their declarations with oxc underisolatedDeclarations, from the type annotations in their own source. Their published types and JavaScript are unchanged.nextly migrate:create --pluginnow writes the migrations index with its type stated (PluginMigration[]), so a plugin built the same way can publish it. -
#2405
5f9ee97Thanks @aqib-rx! - A component's many-to-many now reads back as the list of ids it was saved with on every dialect. Before, PostgreSQL returned the list as a string, SQLite returned it as an undecoded JSON string, and MySQL refused a save of more than one id. The column is now the JSON list column ahasManyrelationship uses (jsonbon PostgreSQL,jsonon MySQL,texton SQLite), on every route that creates a component table. A single stored id reads as a one-item list.Existing installs: a PostgreSQL component table holding the many-to-many as
uuidortext, or a MySQL one holding it asvarchar(36), is offered a one-time conversion to the JSON list column on its next schema apply, andmigrate:createwrites the same conversion into a migration file. Every stored id is kept: a bare id becomes a one-item list, a PostgreSQL array literal becomes the list it holds, and a JSON list stays as it is. On PostgreSQL a value that cannot be read (a malformed array literal) refuses the conversion before anything changes. A generated DOWN reads each list back as its id, and refuses a row holding more than one id rather than drop any. Until the conversion is accepted, saves to such a column may be refused. SQLite needs no conversion. -
#2166
314a592Thanks @aqib-rx! - A component's many-to-many column is text on PostgreSQL on every creation route, so the schema planner proposes no type change between them.Compatibility: a Postgres component table already created with uuid gets a one-time uuid-to-text change on its next preview. Converting existing data is a follow-up for Mobeen; this release does not convert it.
-
#2506
65ecd20Thanks @nextly-agent! - A component held in a group or repeater of another component is now validated against its own fields on every write, its ownvalidateincluded; until now nothing judged it, so existing content that fails it is refused on its next save or publish.
A component placed directly in another component's fields is not stored where the outer component is stored on its own (at the top level of a collection or Single, or under rows, tabs, collapsibles or an unnamed group), and since alpha.67 a value for it there was dropped without a word. Where the outer component is held in a named group or a repeater, its whole value, the directly placed component included, is stored there and validated. Where it is not stored, now:- the admin no longer shows such a field in its forms;
- a write that carries a value for one is refused with
NOT_STORED(only an empty value,null,{}or[], passes); defineConfigwarns about each such field;- the Schema Builder no longer offers one there, and names any an existing field group holds.
Put such a component in a group or repeater instead, where it's stored and validated.
-
#2388
cc30a93Thanks @faisal-rx! - Outside development and test, more of what Nextly writes to the console is masked of email addresses: post-commit hook failures and rejected forgot-password redirects (now written throughconsoleLogger, so with its[WARN]/[ERROR]prefix), failed user searches, and the default auth and database loggers. Addresses written with combining marks (Devanagari, for example) and punycode labels in upper case are masked too. -
#2669
2ed4941Thanks @nextly-agent! - The dashboard's Content card counts only the collections it shows, and shows every collection it counts.system:contenttakes the page of collections first, in slug order, and counts only that page, so an install with many collections no longer counts every one of them on each dashboard load to show fifty.- The card draws every collection in the answer: those the navigation lists in its order, then any it does not. Past a hundred collections the two lists cover different collections, and a counted one used to be dropped.
-
#2713
8266c7eThanks @nextly-agent! - The Count card in the add panel, and content widgets that ask only what they draw.- The add panel's Content group is shown. It lists Count once for each collection the reader may read, and its rows carry the widget's category, so a search by category finds them.
- A card set to a collection names it in its title ("Count: Posts"), and Count names it in its number's accessible name, so two cards for different collections can be told apart.
- Count's Retry asks a failed card again.
- A custom card's cells:
- A cell may declare
drawnFrom, the narrowest tier that draws it. The admin asks only the cells a card's tier draws, and asks the rest when the card widens. - A cell's
whenis judged by the collection that cell reads once the settings apply. - Each cell is keyed by its declared query, so changing one card's settings asks that card again alone.
- The settings preview asks the cells under the sheet's values.
- A cell may declare
- A component's settings are the ones its query was asked with. A stored Filter, Sort or Group by that the query set aside is not handed on.
effectiveWidgetSettingsgives that reading, andKnobFieldis exported besideKnobContext. - Registration refuses:
- a custom card declaring both a query and cells;
- a widget offered per collection with no Collection setting;
- an
offeredvalue outside its closed list ("per-collection","on-request"); - a content setting that no cell's source lets a knob drive.
- A timeseries with
untildraws its intervals up to that boundary, where its rows end. - Whether a caller has a release to read is asked once per batch, not once per collection.
All of it is
@experimentalwhile the content widgets are built. -
#2722
fecf9b4Thanks @nextly-agent! - A contributed widget's text setting is now held to itsmaxLengthwhen the dashboard layout is saved. Before, only widgets in the registry were checked, so a direct layout write could store text of any length on a widget a plugin contributed through its manifest. The settings sheet already refused it. The bound now comes from either channel, and where a registration and a contribution share an id it follows the admin's rule: the registration's settings wherever it states any, else the contribution's. -
#2378
6daef10Thanks @faisal-rx! - Core's welcome, password-reset and email-verification emails now name the staff user on their delivery records, so deleting the user can erase them under any address.persononnextly.email.send,sendWithTemplateand a plugin'sctx.services.emailaccepts a staff user as{ kind: "staff", id }(experimental). -
#2358
a167d97Thanks @nextly-agent! - Core's own rate limits now count in the limit store the site selected with
rateLimit.store: the sign-in limit, the limits plugin routes ask for, the REST
API limit and each API key's limit. On"database", every instance of the site
counts one budget, where each used to count its own.- Keys are stored only as keyed hashes in the selected store. Client
addresses, account ids and API key ids no longer reach the memory store, the
database store or an app'sLimitStoreas given. An app's store of the
earlierRateLimitStorecontract still receives them as before. - Counts roll in 32 buckets per window. An attempt counts for one window
from when it was made, and can be forgotten up to 1/32 of the window early.
The sign-in limit used to slide exactly, and the REST limit reset its whole
window at once. - An app's store of the earlier
RateLimitStorecontract keeps serving
core's own limits, as before.
A store outage after a good start now refuses core's sign-in requests
(503, "Please try again shortly."), on core's/auth/*endpoints and on plugin
routes withrateLimit: "auth", while the REST API, API key and other plugin
route limits let requests through. A store call that takes more than 2 seconds
counts as an outage, and a call that never settles stops holding a place after
20 seconds, so once the store answers again its limits count again without a
restart. Each outage is logged once when it begins and once when the store
answers again. Before, a store failure failed the request. With an
app's own store, an outage stops staff sign-in until the store answers, so
monitor that store.Existing installs see one new production warning, until they select the
database store (rateLimit.store: "database") or say they run one instance
(rateLimit.singleInstance: true). With only a store of the earlier contract,
the warning says that plugin limits count in memory.The memory store holds at most 100,000 live entries (
rateLimit.maxEntries).
When it is full and nothing has expired, a new key is answered as
unavailable, so a sign-in from a new address is refused and an API request is
let through. - Keys are stored only as keyed hashes in the selected store. Client
-
#2692
2a41378Thanks @nextly-agent! - The Count card, and the content settings that let one widget read the collection each card is set to.- Count (
core/count) states how many entries a collection holds. From medium width it adds the published and draft split, and what scheduled releases will publish; at large width, the last 30 days against the 30 before. Each part links to the list that holds exactly it. - Offered per collection: a widget may declare
offered: "per-collection". It is then never placed on the default dashboard, and any number of its cards may be placed, each starting with its collection. - Its query may name no source when a Collection setting supplies one. A custom card may declare several keyed reads (
cells), each answered on its own, and handed to its component ascells. - Content settings reach the card's query: Filter (joined with any condition the widget declares), Sort, Group by and a Date range (
since), each on a collection source. A stored Filter, Sort or Group by naming a field the reader may no longer use is asked with its default instead. - Widget queries take
sinceanduntil(spans from a closed list, measured in whole UTC days or months from the batch's one instant). On a collection source, they take the entry list's workflow conditions_pendingChangeand_inReleaseat the top level. A release condition is answered "not available" for a reader with no release to read.
All of it is
@experimentalwhile the content widgets are built. - Count (
-
#2640
faa31d0Thanks @nextly-agent! -create-nextly-appbuilds its declarations with oxc underisolatedDeclarations, from the type annotations in its own source. The published types and JavaScript are unchanged. -
#2438
aa707beThanks @nextly-agent! - The dashboard's Add widget now opens a panel beside the dashboard. It lists every widget you may add in core's groups (Overview, Team, Plugins), with the cards you removed first so they can be put back where they were. You can search it by name, description, category, group or plugin (a removed card by its name or by its Removed section's text), and add several cards in one visit: each new card goes to the end of the dashboard and is ringed so you can see where it landed. A one-card widget already placed shows as on the dashboard. At the card limit, adding is turned off and putting a removed card back still works. The panel announces search counts and each card it adds. When it closes, focus goes to the last card added, or back to Add widget.The layout read now gives each widget the panel offers a
rank, its place in that order, so the panel can list a placed widget where it would be offered. -
#2528
e0f9d63Thanks @nextly-agent! - Dashboard cards now arrive one at a time. A card whose data is slow, or fails, no longer holds back the cards that read other sources.POST /api/dashboard/querystreams its answer when the request sendsAccept: application/x-ndjson: one{"index", "slot"}line per card, written as each settles. Cards one source answers with a single shared call arrive together, as that call ends. Each line is the slot exactly as the JSON answer holds it, with its dates formatted the same way. A request without that header gets the same JSON answer as before. Both responses now sendVary: Accept. Anything that fails the whole batch (authentication, the body, the caller) still answers with the usual error before the stream starts. If the reader leaves mid-stream, the cards still running are told to stop through their signal.- The admin dashboard asks for the stream and draws each card as its slot arrives. A refresh keeps each card's numbers until its new answer lands, and only the cards still waiting show as loading.
- The "is there content" check behind the
content:emptycondition and the onboarding "first entry" step now counts at most 8 collections at once and stops at the first one with an entry. It has a 5-second budget. When the budget runs out it answers "unknown", which is never read as empty: the empty-install cards are not offered, and the first-entry step stays open. Counts already running when it stops cannot be cancelled; they finish and are ignored.
-
#2301
eba8f00Thanks @nextly-agent! - A dashboard card lays itself out by its own width, small, medium or large, never by the
window's. A card below 340 px is small and from 700 px large, and the dashboard predicts each
card's tier from its own width and the card's span before the first query, so each card asks
only for what its tier draws.- A list or table card draws three rows, titles only, when small; its number of items with a
line of detail when medium; and a table with a heading per column when large. When its answer
holds more rows than it draws, it says "More exist", and for a list from one collection that
is a link to the collection's list view with the card's filter and sort. - A breakdown draws its 3, 5 or 8 largest groups and says how many more there are, or that more
exist when the answer itself was cut short. - An over-time card shows the latest value and the total over its range when small, dates its
line's two ends when medium, and adds dates between them when large. - The Create card offers 2, 3 or 6 shortcuts and the rest in a More menu; the Team card shows
the number of people alone when small; the Collections and Singles tiles take as many columns
as the card fits. - A card whose width grows into a tier that draws more asks again on its own; a card that
shrinks asks nothing. Nothing in a card is clipped and nothing scrolls inside it. - The entry list honours a
sortin its address for a column its sort menu can order by.
- A list or table card draws three rows, titles only, when small; its number of items with a
-
#2367
e7887fcThanks @nextly-agent! - Dashboard charts show their values and colour their groups. A chart over time labels its value axis at zero and its highest value on medium and large cards, circles its latest point at every size and labels its value from medium up, and names several series in a legend. A chart of groups orders its groups by count, ties by label in the admin's locale, before a card's size cuts them, and colours each bar by its group's value, so a status is the same colour on every card. Each chart's text alternative states its range, total, latest and highest values, or its groups, total and the group with the most, saying when counts are lower bounds. The light theme's third chart colour is a step darker, so every chart colour holds 3:1 on a bar's track and on a tooltip as well as on the card. -
#2696
e586815Thanks @nextly-agent! - The dashboard's Needs attention and Create cards are offered exactly when they would have something to show, and the Content card's rows always say what they are.- Needs attention is offered on content it could count: a collection or Single with a published source, or releases. A reader whose only collection is mid-migration, and who may not read releases, is no longer given a card that can only say "not available".
- Create is offered only when it would offer a collection: one the reader may create in that does not turn creating off (
admin.disableCreate, as form submissions do). A site whose only writable collection is form submissions no longer gets an empty Create card. system:contentkeeps each row'skindwhatever a query selects, so a collection and a Single that share a slug always stay apart.
-
#2508
f3dd57cThanks @nextly-agent! - On the dashboard, a keyboard drag cut short when the dashboard narrows to one column now leaves focus on that card's menu, where the move goes on, instead of on the page. Resetting the dashboard to its default also clears the outline on the card placed last, so a card the default reuses no longer looks freshly placed. -
#2480
ddc8279Thanks @nextly-agent! - Dashboard cards are now dragged by their handle with a drop line, and the dashboard can be customized on phones. While a card is held, the other cards stay where they are, the held card dims, and a line shows where it will land: beside the card it will follow, or across the dashboard where it will start a row. The order changes once, on release. The keyboard works the same way: Space or Enter picks a card up, the arrow keys move it one place at a time, Space or Enter puts it down, and Escape cancels. Each step is announced, and so is the move with both positions. A moved, resized, added or restored card is outlined until the next change, and without animation for anyone who asks for reduced motion. Customize is now offered at every width. Where the dashboard shows one column, the handles are hidden and the card menu moves, resizes and removes cards, and the bar fits a phone. A drag in progress when the dashboard narrows to one column is cancelled, and says so.In the add panel, a removed card is found by its widget's description, category, group and plugin, as the widget's own row is, and each visit to the panel starts with nothing announced from the last one.
-
#2700
0962243Thanks @nextly-agent! - A reader can put a Note on their dashboard: Markdown of their own, private to their layout, drawn read-only with no HTML and links held to http, https and mailto. Widget settings gain an experimentaltextareatype with amaxLengthcounted in characters (code points) by one counter,textLengthonnextly/config, used by the settings sheet and the layout write alike.offeredgains an experimental"on-request": a card added from the add panel only, never placed by default. A dashboard over its size limit is told "This dashboard is full: remove a card or shorten a Note". -
#2472
ee72728Thanks @nextly-agent! - The "database busy" code core reads is now held in core, so the error module reached from every client-safe entry no longer imports the database adapter, and the admin's browser bundle no longer reaches a database package. The code is unchanged, and a test holds it equal to the adapters'. -
#2598
bc33333Thanks @nextly-agent! - Filtering by a date works on SQLite. A filter oncreatedAt,updatedAtor a date field of the collection itself with an ISO 8601 value used to answer 500 on SQLite, the admin's Created and Updated filters on every list among them. It now narrows the list and its count, as it does on PostgreSQL and MySQL. A bound with a fraction of a second is compared exactly on SQLite, which used to floor it to the second. On PostgreSQL, an instant written with an offset other thanZis compared as the instant it names, where its offset used to be dropped.A date is compared with an instant with its offset (
2026-09-01T09:00:00Z) or a calendar date (2026-09-01, the start of that day in UTC), read the same way on every database. Any other value, including a time with no offset, is now refused with a 400 validation error (FILTER_INVALID_OPERANDin its field errors) on every database, where PostgreSQL and MySQL used to pass it to the database to interpret. -
#2536
4735e39Thanks @nextly-agent! - Experimental: a workflow definition can now be declared in code and attached to collections and Singles. This release only declares, validates, registers and types a definition. Nothing enforces it yet: content is not tracked through stages, no step can be taken, and publishing works exactly as before.defineWorkflow, fromnextly/configornextly, declares:- stages, one or more of them allowing publishing, but never the first, nor one the system moves content to without an approval;
- steps between stages, of kind
submit,approve,request-changesormove; - settings kept for later use: what an edit to approved content does, where the next draft starts, where removed stages go and whether approvals stand.
reviewWorkflow()is a ready-made Drafting → In review → Approved definition. Each definition is checked when declared. An error names its place and the known names, for examplesteps[1].to: `aproved` is not a stage. Known stages: drafting, inReview, approved.. A misspelt stage in a step fails to compile.The
workflowoption on a collection or Single attaches a definition. It needsstatus: trueand drafts enabled. An attachment that cannot keep a draft beside the live version is refused at boot, before anything is written, on hot reload, and innextly buildandgenerate:types. The refusal says which condition is missing.Each attached workflow is registered with:
- a definition hash: sha256 of its structure, which labels and attachments don't change;
- the path of its
nextly.config.ts.
generate:typesandnextly buildadd a<Name>WorkflowStageand<Name>WorkflowStepunion for each attached collection and Single, and aworkflowsentry inConfig.An internal, never-exported
defineWorkflowfor content states inlib/content-statesis renameddefineContentStates. -
#2300
f5b1fffThanks @nextly-agent! - Deleting a media item now removes every file stored for it: the original, the thumbnail made at upload, and every image size, on local disk, S3, Vercel Blob and UploadThing.Privacy note. Until this release, deleting a media item left its upload-time thumbnail stored, on local disk under
public/uploadsand on S3 and Vercel Blob, where it stayed publicly reachable at its old URL. (UploadThing was not affected.) To find those thumbnails, runnpx nextly media orphans: it lists them by stored path and changes nothing, and you remove them by hand from the folder, bucket or dashboard they are in. Run it again with--applyto have it remove the ones whose place shows they are media's: it asks about each batch of files just before removing it, keeps any an item has come to refer to, and stops if it cannot check. On S3 it matches keys as written, so/media/...ormedia//...keys are never taken formedia/...ones. Run it from the project's own directory to remove files: with--cwdnaming another project,--applyrefuses, since the database settings were read before it. It lists a file as left behind only under aprefixthe media collection has to itself, one no other collection on any storage shares, contains, sits inside or begins. Without one, as by default, another collection or upload can store a file of the same form in the same place, so it lists what it finds there as files nothing shows are media's, and names any collection that shares media's place: check those with more care. The command looks only under media's prefix as it is now set, so run it before you give media a prefix: the thumbnails earlier deletes left stay where they were, outside it. It looks only at the storage the media collection uses now. If your site used another adapter, bucket or folder before, point the media storage at that one and run the command there too. The command leaves out thumbnails stored in the last hour, which may belong to an upload still in progress: if you deleted, before upgrading, an item uploaded less than an hour before you run it, run it again an hour later. On UploadThing, whose apps can hold other uploaders' files, it lists what it finds by file key and uploaded name: remove the ones that are yours from the UploadThing dashboard. Run it again after a delete whose log names a file it could not remove, or whose process stopped before it finished.- Deleting a folder with its contents removes every file of every item in it and in its subfolders, once their records are gone. It removed none before.
- A file the storage refuses to remove is logged with its path and the media item, so it can be found.
@nextlyhq/storage-uploadthing: a delete UploadThing refuses (an outage, a refused token, a rate limit) now fails, so it is retried and logged like any other, rather than read as done while the file stays stored. A file already gone is still not an error.- A crop inside a plugin's
ctx.db.transactionon SQLite removes the sizes it replaced once that transaction commits. They were kept before, stored indefinitely. - A crop or rotation whose new sizes include
thumbnailremoves the thumbnail made at upload, which nothing refers to any more. - A crop on SQLite saves its regenerated sizes. It failed before, because the sizes were written as an object the driver cannot store.
- A delete and a crop of the same item at once leave no file of the item behind, and two crops at once leave no file behind that nothing refers to.
- Storage adapters can now list their files (
list(), optional).@nextlyhq/storage-s3,@nextlyhq/storage-vercel-bloband@nextlyhq/storage-uploadthingimplement it; an adapter without it is reported as one that cannot be searched.
-
#2381
95253eeThanks @faisal-rx! - A read atdepth=0returns stored ids for a Single's upload fields and for many-to-many fields kept in a junction table, as it already did for every other relationship. A Single's uploads were populated at every depth, and a junction field had no value atdepth=0. Populated reads are unchanged. -
#2435
1204813Thanks @faisal-rx! - Who may read, change and revoke API keys follows the permission, not the key's owner, in the admin, the REST routes and the Direct API alike.- Anyone who may read keys (
read-api-keys, orupdate-api-keys) sees every key, not only their own. A key still shows only its prefix after it is created, so none can be copied. - A key's owner (name and email) is shown only to someone who may also read users. A person always sees themselves on their own keys, and the admin's key list says "Hidden" for an owner it may not show.
update-api-keysrenames any key, anddelete-api-keys(orupdate-api-keys) revokes any key. Creating a key is still only for yourself.- The dashboard's key count covers every key, as the list does.
The Direct API's
apiKeysnamespace now honoursoverrideAccess: false, set on the call or on the instance, and judges itsuserby these same rules. It is now onawait getNextly()'s instance too, as on the module-levelnextly.- Making or changing keys as an API key is refused.
- A checked call that names no
useris refused withAUTH_REQUIRED. - A call that names a different
useris judged as that person, not by the instance's API key.
Compatibility: over REST, a person holding
read-api-keysbut not super-admin now lists every key where they listed only their own. A holder ofupdate-api-keysordelete-api-keyscan now change or revoke keys they do not own, where before they got 404. Through the Direct API, only calls that ask to be checked change; trusted calls, the default, behave as before. - Anyone who may read keys (
-
#2453
12cf1d5Thanks @faisal-rx! - The Direct API's role, permission and field-group writes honouroverrideAccess: false, set on the call or on the instance, as the REST routes do.nextly.roles.create,update,deleteandsetPermissions,nextly.permissions.createanddelete, andnextly.fieldGroups.create,updateanddeleteask the permission the matching route asks:- roles:
create-roles,update-roles(also forsetPermissions) ordelete-roles; - permissions: their own action, or
manage-permissions; - field groups: their own action on
settings, ormanage-settings.
A super-admin passes. An API key is judged by its own grants. A checked call that names no
useris refused withAUTH_REQUIRED.Compatibility: only calls that ask to be checked change. Trusted calls, the default, behave as before.
- roles:
-
#2531
7a9bedeThanks @faisal-rx! - The Direct API's user and role writes (users.create,users.update,users.delete, androles.create,roles.update,roles.delete) now honouroverrideAccess: false, set on the call or on the instance. Each is checked as the named caller against the same permission the matching REST route asks for, and refused withFORBIDDENwhen the caller lacks it, before anything is written. A checked call that names no user is refused withAUTH_REQUIRED. A role change made this way follows the same super-admin rule as REST: only a super-admin may give anyone a role that is, or inherits, super-admin. An API key is judged by its own grants. Trusted calls, the default, are unchanged. -
#2417
a9f0549Thanks @nextly-agent! - Mod+S now runs the editor's Save action, so the shortcut always saves the way
the Save button does. Two cases change. On a Single with Draft/Published, Mod+S
used to save without naming a status; it now saves a draft, or stores changes
to a published Single the way Save changes does. Creating an entry, Mod+S now
saves a draft, as the Save draft button does, instead of saving with no status.Submitting the full-page entry or Single form with Enter now saves the way Mod+S
does: a draft, or the change to a published document. It never publishes,
even where Publish is the leading button; before, it saved without naming a
status. Whether Enter submits in a given field is unchanged.useDocumentStatus()now reports the document's status inside the full-page
entry editor, including from a plugin's document view. It returnednull
there before; the quick-edit modal and the Single editor already reported it.The header's save indicator now reports the document's save; recovery points move into its tooltip. A new entry shows 'Unsaved changes' once edited.
After a save, an entry or Single shows what the server stored, such as a trimmed title or a rewritten page layout, rather than what was sent. Anything typed while the save was in flight stays on screen, still unsaved. A save that sent a link the editor cannot see keeps showing the form as sent, so the link is not dropped from the next save.
The Page Builder's screen now offers the document's Save and Publish (or Save draft) in its own top bar, the same actions the editor's header offers. And a passage still being typed in the builder is finished before any save, whether it was started from the keyboard, a button or a menu; before, only Mod+S finished it. Once its page is saved, the builder shows it as saved, and leaving no longer warns, until it is edited again. Its Save and Publish wait for a change the page depends on that is still being written, as the header's do, instead of ignoring the press.
A failed save's Retry belongs to the document and language it failed in: switching language, or to another document, clears it, so it can never store or publish a different translation. The Discard draft confirmation can no longer be dismissed while the discard runs. Confirming it twice before the dialog updates discards once. After a save, the editor stops offering a recovery point from before it. A save waits for every change the page depends on, including one started while it waits, and says so if changes keep arriving. A change that failed to save names itself and where to make it again, rather than offering a Retry that would fail the same way. For plugins,
useDocumentActions().run("delete")now resolves once the delete is answered, with what it came to, anduseDocumentActions()reportsstoredAt, when the document was last stored in this session. -
#2400
335a798Thanks @faisal-rx! - Outside development and test, the log lines written by Nextly's collection, relationship, media, auth, schema and other domain services are masked of email addresses: they now go through the service's logger, orconsoleLogger, rather than straight to the console, with errors and values as fields. A lineconsoleLoggerwrites without fields no longer carries an empty trailing argument. -
#2374
90d0ac7Thanks @faisal-rx! - Outside development and test (an unsetNODE_ENVincluded), email addresses that Nextly logs through its loggers,consoleLoggerand aloggerthe app passes it included, are masked to their first character and domain (j***@example.com), and the log transport leaves message bodies out. Search production logs by domain and first character instead of the full address. -
#2474
26f098eThanks @faisal-rx! - Emails can carry the site's brand without editing HTML. Brand settings (a logo and its width, an accent colour, the background and content colours, a font from an email-safe list, and footer text and links) are stored by core and read byGETandPATCH /api/email-templates/brand, which take the permissions to read and to update email templates. Core's default layout is now written in them, the buttons of core's built-in emails take the accent, and every layout and template can read them as thebrandvariable (brand.htmlholds the logo, footer and link markup they make). A background or content colour a site chooses is kept in dark mode too, where the default layout used to swap in fixed dark colours.Nothing changes for an existing site until someone changes a setting: each setting starts from what the default layout showed, which renders every email byte for byte as before. On upgrade, the default layout and built-in emails still as core shipped them move to the new versions; ones a site edited stay as they are, as do the site's own layouts.
GET /api/email-templates/brandsays which emails the settings reach and why the others are not reached. In a layout's data,brandis always the settings: a send's ownbrandvalue still reaches the template body, but no longer a layout, and a template whose send supplies nobrandreads the settings. The settings are also always available asnextly_brand, which core's built-in emails read, so a send carrying abrandof its own changes nothing in them; a send's ownnextly_brandvalue is ignored. A template kept on the engine before Liquid now writes HTML marked as trusted unescaped, as Liquid does. -
#2566
bf8f378Thanks @faisal-rx! - The email brand settings table is namedemail_brand, like the other email tables.
…and 15 more changes. The full list of all 109 is in packages/nextly/CHANGELOG.md.
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/uicreate-nextly-appnextly