0.0.2-alpha.67
- Adding Nextly to an existing Next.js app now installs everything it needs and finishes, on npm, pnpm, Yarn and Bun.
- New command:
nextly users:clear-plaintext-passwords. Earlier releases stored some passwords as typed instead of hashing them. Such a value never let anyone sign in, but it is readable in the database and its backups.
Before you upgrade1
- 1
Run
nextly migrate, then install each configured pluginRun these against your production database once, after deploying the new packages and before the new version serves traffic:
npx nextly migrate # required for every project npx nextly plugins install <plugin-name> # once for each plugin in your confignextly migrateis required for every project, with or without plugins. 0.0.2-alpha.67 adds two core tables,nextly_version_pinsandnextly_version_views, and a production server never creates tables itself. Untilnextly migratehas run, deleting documents fails with "relation … does not exist", while reading and saving still work.If your project configures a plugin (for example
@nextlyhq/plugin-form-builderor@nextlyhq/plugin-page-builder), a production server refuses to start until the plugin has been installed on that database. It stops withPLUGIN_NOT_INSTALLEDand names the command to run. Development only warns. A project made from the blog template before 0.0.2-alpha.67 needs:npx nextly plugins install @nextlyhq/plugin-form-builderProjects created with the 0.0.2-alpha.67 blog template run this install in their build script automatically. Installing leaves your existing content as it is, and running it again is harmless. See Installing a plugin.
What's changed90
Released 21 packages at 0.0.2-alpha.67 in lockstep. Every package below ships at this version.
Patch Changes
- An update or delete by
whereunder access rules now refuses awhereon a field the caller may not read in the collection bulk service itself, before any row is found or written.More
The Direct API already refused it; the REST bulk routes, which reach the service without the Direct API in front, did not, so a caller allowed to write but not to read a field could guess its value and learn from which rows changed whether it matched. Callers with
overrideAccess: trueare unaffected. - A class whose id was
__proto__could not be renamed again after a refused save.More
The class manager records which rename of each class is the live one, and it kept that record on a plain object keyed by class id, where
__proto__reads back an inherited object and a write to it stores nothing. The refused rename never released its pending name, so retrying the same name was taken as no change at all.The record is now a
Map, and the pending names are read as the record's own entries in both the editor and the class manager panel, so a class behaves the same whatever id it carries. - A plugin cleared by
clearServices()now reaches every service it was started with during its teardown, including those its context looks up through the container itself, such asctx.services.jobsandctx.audit.More
Those found the container empty, so a
destroythat queued final work or wrote an audit entry failed, or its entry was dropped. A secondclearServices()before the next registration, or one that arrives while that teardown is still running, no longer takes away the services it needs. - A plugin cleared by
clearServices()is torn down when services register again, and itsdestroycan now still reach the services it was started with.More
clearServices()used to empty the container they lived in at once, so a teardown that opened a transaction, asctx.db.transactiondoes through the adapter on SQLite, failed half-way and the new registration went on with the old plugin's cleanup unfinished. The plugin docs no longer saydestroyruns on a hot reload in the author guide's example or the contributing checklist. - A plugin route that sets
csrf: truenow checks the origin of a POST, PUT, PATCH or DELETE that no session authenticates, before its handler runs.More
Before, such a request was not checked at all, so a guest-facing route, such as a cart that keeps its own cookie, accepted a cross-site form post. The request must name your site or an origin in
NEXTLY_ALLOWED_ORIGINS, as a signed-in write must; one naming another origin, or none, is refused withCSRF_FAILED. It is not asked for a CSRF token. A session cookie that is expired or malformed authenticates nobody, so its caller is checked the same way, and is no longer asked for a token it could not hold.Only an
Authorization: Bearertoken, with the scheme in any case, now exempts a request no session authenticates from the check, whether or not the token is a valid key. A request a session authenticates is checked as the session's, whatever header it also carries. Any other scheme, such as the Basic, Digest, Negotiate or NTLM credentials a browser attaches on its own, no longer does.The plugin route documentation now lists which callers each
csrfsetting checks.Compatibility: a client that writes to a public route with
csrf: truewithout a session or aBearertoken, such as a server job, must now sendOriginwith your site's origin or an allowed one, or it is refused. A route meant for webhooks or other server callers should leavecsrfunset, which still checks no anonymous write. No first-party plugin route setscsrf: true. - Retention never removes a version a feature has pinned.
More
A feature registers itself as a pin holder once, then points named slots at versions of a document (
versionPins.holder(name).pin(tx, { ref, slot, versionNo })); moving a slot frees the version it left, and a version stays while any slot of any feature points at it.registerVersionPinHolder,versionPinsand their types are exported fromnextlyas experimental, and the versions docs describe them. Pins are kept in a new core table,nextly_version_pins, whichnextly migratecreates on an existing database, and they go with their document or Single when either is deleted. A pin takes the document's write lock, so it cannot slip past a save's retention pass. - Whether a caller may filter, sort, group or search on a field now follows whether the field's
readrule lets that caller read it.More
Before, any field with a
readrule was refused for every caller, the ones the rule allows included.The rule is asked for the caller with no entry, since a query has none yet:
A rule that allows the caller outright (by role, permission or identity, such as
({ req }) => req.user?.role === "admin") now lets that caller filter, sort and group on the field, and a collection's search matches on it for them.A rule that refuses the caller is refused as before, with a
400naming the field as written:FIELD_NOT_FILTERABLE,FIELD_NOT_SORTABLEorFIELD_NOT_GROUPABLE. A collection's search leaves the field out.A rule that needs the entry to decide, such as
({ req, data }) => data?.owner === req.user?.id, cannot allow without one, so the field cannot be queried by anyone, its owner included, and search leaves it out. A filter or an ordering on it would reveal its value on the entries where it is hidden. To query by ownership, filter on the owner field itself.
A widget data source still offers a group key only when no
readrule guards the field, since one source serves every caller. - A filter that is not well formed is now refused instead of being run without its bad part.
More
Before, each form below was dropped on the way to the database, so the read matched more entries than the filter stated, and a filter that narrowed by owner or team could quietly return every entry. Through REST each answers
400with aVALIDATION_ERRORnaming the problem atwhere.<path>; through the Direct API,find,count, grouping, timelines and the bulk-by-query writes throw the same typedNextlyError.Newly refused, and what to write instead:
An unknown operator (
{ title: { equal: "a" } }, orstartsWith) —FILTER_UNKNOWN_OPERATOR. Use one ofequals,not_equals,greater_than,greater_than_equal,less_than,less_than_equal,like,contains,search,in,not_in,exists,near,within.An unknown field, or one with no column on the collection's table, such as a many-to-many relationship (
FILTER_UNKNOWN_FIELD). Check the field's name; for a many-to-many relationship, read the related ids first and filter onid.A dot path that is not into a component (
{ "author.name": … },{ "meta.key": … }) —FILTER_UNFILTERABLE_PATH. It used to compare the wholeauthorormetacolumn with the value. Look the related entry's id up first and filter on the relationship field:{ author: { equals: id } }.undefined, as a field's value or an operator's —FILTER_UNDEFINED_VALUE. Leave the condition out.An empty
in({ id: { in: [] } }) —FILTER_EMPTY_IN. It matches no entry, so skip the query, or leave the condition out if you meant "any". An emptynot_instill excludes nothing.An
andororthat is not a list of filters, or a list with a gap or a non-object in it —FILTER_INVALID_LOGICAL/FILTER_NOT_AN_OBJECT. Writeand: [{ … }, { … }].An operand of the wrong type —
FILTER_INVALID_OPERAND: an object or list given toequals(useinfor several values), a value that is not a list given toinornot_in(write["value"]),nullgiven to any operator or as a field's value (write{ exists: false }, or{ exists: true }fornot_equals: null), a list given as a field's value (write{ in: [...] }),existswith anything buttrueorfalse, and anearorwithinthat does not parse. A geo operand now parses only in its documented form: an unknown unit (it used to mean metres), parts beyond the last one the format names (they used to be ignored), or a number with trailing text (5abcused to read as 5) is refused.An empty condition (
{ title: {} }) —FILTER_NO_OPERATOR. Name an operator or leave the field out.A malformed language filter (
_translatedwithout alocalestring and a knownstate, or anywhere but the top level) —FILTER_INVALID_OPERAND/FILTER_MISPLACED_KEY.A component field or a geo operator inside
or—FILTER_NOT_IN_OR. Both are applied as a condition every entry must meet, so insideorthey turned the alternative into a requirement. Move them to the top level or intoand.A REST
wherethat is not valid JSON, or not an object —FILTER_INVALID_JSON/FILTER_NOT_AN_OBJECT. It used to read as no filter at all.A REST request carrying both
where=JSON andwhere[…]parameters —FILTER_GIVEN_TWICE. The bracket conditions used to be ignored. Send one form.A REST bracket key that is not bracketed segments (
where[title]equals=a), that names__proto__, whose list index is 50 or more (the most conditions a filter may have), that nests under a value another parameter gave, that mixes list indexes and named keys at one level, whose index has leading zeros, or that repeats a condition already given —FILTER_INVALID_KEY. A__proto__field key in a JSONwhereis refused as an unknown field. Fields namedconstructororprototypefilter as any other field does.A REST filter on the owner column (
created_by,createdBy, any spelling that resolves to it such asCreatedBy, or a dotted form) —FIELD_NOT_FILTERABLE. It used to be removed silently, soor: [{ createdBy: … }]matched every entry. The column is never returned, so it cannot choose rows either.
Two meanings are corrected rather than refused: an empty
ornow matches no entry (it matched every entry), and anorholding an empty member such as{}now matches every entry, as a condition that is always true does.The REST bracket form also works again.
where[title][equals]=alphawas read into a field namedtitle]equals, which matched no column and was dropped, so every bracket filter returned all entries. A comma now splits values only forinandnot_in, where a single value becomes a one-item list:equals=Hello, worldcompares with that text, and anearpoint reaches the geo filter intact. - Database adapters can tell whether the calling async flow is inside a transaction that has not finished yet, with
transactionOpenInFlow()(@experimental).More
Each flow gets its own answer, never the connection's: on SQLite, another request's open transaction does not make it true. It is true inside transactions that core's services open through Drizzle on PostgreSQL and MySQL, as well as inside the adapter's own
transaction(). - A fresh install no longer starts without the foreign keys a plugin's or the app's table declares to a collection's, Single's or field group's table.
More
If one cannot be created, the first boot stops and names the table, the key and its target. The first boot also no longer prints a warning for each of those keys before creating them.
- On SQLite, an effect held until an enclosing transaction commits (an after hook, an event, a cache flush) now runs in the async context of the write that registered it.
More
It used to run in the context of the code that ran the outer transaction, so the caller pinned around the write was gone. In particular a scoped API key's scope was lost: a hook's nested gated call was judged by the key's owner, a super-admin among them, instead of the key. The effect still runs outside the finished transaction. PostgreSQL and MySQL were not affected; they run such effects at once.
- A dashboard with more cards than one save may carry can be saved again
More
When a person regains access to many cards at once, their arrangement can hold more than one save may carry, in number of cards or in size. Saving now leaves out cards they have removed, latest first, until it fits. Cards still on their dashboard are never left out.
nextly/confignow also exportslayoutSizeProblemandMAX_LAYOUT_BYTES, the layout endpoint's own check of whether a submission fits.Retired dashboard widgets now merge only into the same card of their successor, as the widgets specification describes, and a widget retired twice reaches the widget that exists now. This release retires no widget, so no dashboard changes yet.
All 90 changes
- A large backlog of due releases settled more slowly than it had to.
More
Each drain pass decided which due releases it could mark published by looking every one of them up in the list of all due releases, so the decision grew with the square of the backlog: about two to four seconds at twenty thousand due releases, an imported schedule's size, taken out of a pass bounded to twenty or twenty-five.
The pass now looks them up in a set built once, so the decision grows with the backlog. Which releases may settle, and in what order, is unchanged; a pass only reaches further into a large backlog before its deadline.
- A link saved in a pattern now points at the element the page actually shows.
More
When a selection held a visible renamed element and a hidden copy with the same id (hidden by a visibility condition), a link from an unrelated block could keep the old id, which nothing renders once the hidden copy is left off the page. The visible element now decides where such a link points. A hidden element decides only when nothing visible carries that id, so a link and its target stay together when the condition later shows it. Hidden elements still get their own ids put back.
- Adding Nextly to an app reached through a symbolic link (the project folder itself is a link) writes every file again.
More
nextly.config.ts, directly in the project, was taken for a file written through a link and skipped, while the routes that import it were written. The project folder is now left out of that check for every file alike, and the folders below it are still checked. - A localized single can be read in production.
More
At a production boot, a code-first single was registered without its
localizedsetting, so reads looked for its translated fields on its own table, where a migrated database does not keep them, and every read of a localized single (pages, the REST and Direct API, dashboard widgets) failed with "no such column". The dev server registered it correctly, which hid it. Singles are now registered as collections are. - A boot now refuses to start whenever a plugin's or the app's table that holds no rows yet lacks its foreign key to a collection's, Single's or field group's table, not only on the boot that created the tables, so a restart after a partly applied first boot no longer serves the table without its key.
More
A table that holds rows is left to
nextly db:syncand migrations. The first boot again attempts, and reports, a key to any other table that does not exist yet. - Adding Nextly to an app inside a monorepo now installs with the workspace's package manager, and approves its build scripts at the workspace's root.
More
The workspace is the nearest one above the app, up to its repository's root, whose
pnpm-workspace.yamlpackagesorpackage.jsonworkspaceslists the app; its lockfile names the manager, or pnpm when itspnpm-workspace.yamllists the app before the first install. An app inside a pnpm workspace no longer getsnpm installfrom annpxlaunch, and an app no workspace lists keeps the manager that launched the generator, whatever lockfile sits above it. A new project keeps the manager that launched it. When the app already approves a specific esbuild version in pnpm's settings, that rule is kept, and the generator's note, which a failed install now repeats, says to approve the version pnpm names if the install then stops on another one. - A new project starts Nextly when its server starts, so
/api/healthanswers healthy on a freshly started server without waiting for other traffic, and code that reads through thenextlyproxy finds it running.More
The blank and blog templates ship an
instrumentation.tsthat does it, and adding Nextly to an existing app writes one, or, when the app already has one, leaves it alone and prints the lines to add. Before Nextly has started,/api/healthanswers 503 with the generic error and logs, once, how to start it. - Every admin user now has a personal list of notices: things that happened to their work that they should know about.
More
A notice is for one person only, is stored once per occurrence, keeps a snapshot of what it is about so it still reads after that is deleted, and records when it was read. Read them with
GET /api/notices, count them withGET /api/notices/count, and mark them read withPOST /api/notices/read, all for the signed-in person (API keys are refused); or from server code withnextly.notices.find(),count()andmarkRead(). Notices are kept as long as the activity log keeps its records, and are deleted with their person. Core features add their own kinds of notice through one registered, typed kind; plugins cannot add kinds yet. All of this is experimental.Run
nextly migrateto create thenextly_noticestable on an existing installation. Until then lists are empty, nothing is recorded, and one warning names the table and the command. - A plugin can contribute a full-screen view of a document, at its own address under the document's:
/admin/collections/<slug>/<id>/<path>, or/admin/singles/<slug>/<path>for a Single.More
The admin keeps the document's loading, soft lock, recovered-work offer, leave guard and save shortcut around the view, so the plugin implements none of them. Declare a view for one collection under
contributes.admin.views[slug].documentViews, or for every collection and Single with a top-level field of a type the plugin contributes undercontributes.admin.documentViewswithfieldType.navigateToin@nextlyhq/plugin-sdk/adminmoves to another admin page and refuses any other address.PluginDocumentView,PluginFieldTypeDocumentView,DocumentViewPropsandnavigateToare experimental. Startup is refused when a view's path is malformed, reserved (versions,api), claimed twice on one document, or offered on a field type its plugin does not contribute.nextly generate:typesnow writes the plugin component import map, or removes a stale one, in an app that defines no schema in code. Such an app, whose schema is made in the Schema Builder, rendered empty plugin pages and settings components in a bundled admin, because the map was skipped with the types. - A plugin can no longer declare a hook point under a prefix core's own seams use (
email.,admin.andcollections.): boot now fails withhook-point-reserved, naming the plugin and the point.More
A plugin whose slug is
admincould otherwise declareadmin.navas a decision, and core's own admin navigation filter would then be refused on every request. - A plugin can no longer turn an account into the kind core makes for a person another system has verified: active, verified and without a password.
More
Through
ctx.services.users, an update that would leave an account that way is refused withFORBIDDENand changes nothing: verifying an active account that has no password, activating a verified one that has none, both at once, or reactivating one core made for a verified person. Core makes those accounts only when one of the app's login plugins hands it a sign-in. Such an account still takes a plugin's other edits, and a plugin may deactivate it or give it a password, but may not move it to another address unless the same write gives it a password. An active account with no password that is not verified stays allowed. - A plugin's email to one of its signed-in people can now name them:
ctx.services.email.sendandsendWithTemplatetakeperson, by kind and id.More
The delivery record of the message keeps the person's kind and id, on the row of the
toaddress, in newprincipal_kindandprincipal_idcolumns ofemail_deliveries. Apersonthat cannot name one, or names a kind no running plugin registers, is refused before anything is sent.ctx.principals.reportDeletednow also erases, in the same transaction, the address digest on every delivery record naming the person, and names them there by the keyed digest of kind and id in place of the id. Every record written for the person afterwards, the email telling them their account is gone included, is written without an address digest, even when it is sent inside the report's transaction. Records of anyone else at the same address, a staff user's among them, stay as they were.Every delivery record is written through one insert, which takes the lock a deletion report waits on; a test holds any new writer to it.
nextly migrateand boot add the columns and their index on all three databases, and rewrite no row. - A plugin can limit attempts on keys it chooses, before the work they guard runs (
@experimental).More
It declares named rules in
contributes.limits: rolling windows, or a growing wait after attempts in a row. The app refuses to start on a rule outside the documented bounds. The plugin then reserves throughctx.limitson 1 to 16 entries, each a rule and a key. A reservation is decided and counted atomically in the selected limit store, so instances sharing it admit no more than the rules allow. Each entry chooses whether it can refuse (enforce) and when it counts (count), and gets its own verdict back.release,reset,checkandclientKeyare there too. A store that can't answer givesunavailable, never an admission. Keys are stored only as keyed hashes, namespaced to the plugin. The types are exported from@nextlyhq/plugin-sdk. ctx.auth.completeLoginnow refuses a plugin's strategy name that is only its slug and a colon (acme-google-auth:), with no name for the method after it.More
Such a login was recorded on the audit trail as the plugin alone, so a plugin with more than one way in could not be told apart on the rows. Name the method after the colon (
acme-google-auth:google); plugins that already do are unaffected.- A plugin-defined signed-in person can now read and write collections, under access rules and nothing else.
More
Collection and field rules receive the person as
principal, beside auserthat isnull, and collection hooks receive them too. A person is admitted only by a code-defined rule that names them, or one set totrue: a collection with no rule for the operation refuses them, database permissions and the super-admin bypass never apply, and a person can never publish. Every other caller's rule receivesprincipal: null.Collection and Single operations on the Direct API, and a plugin's
ctx.servicescollection calls, take aprincipaloption to run as a person. The call is refused before it runs if it also asks to skip access rules or the filterable-field check (frameworkFilter), names a staff user or an API key, or names a person core did not make; any other operation refuses the option, and it cannot be a Direct API instance default.findSinglesmade as a person lists, and counts, only the Singles the person may read. A document created as a person has no owner.nextly.principals.byIdandctx.principals.byIdfind a person through the kind'slookup, checked as a resolver's answer is.An update or delete by
wheremade under access rules (a person's, or any call withoverrideAccess: false) now refuses awhereon a field the caller may not read before it writes anything, as a read already did; the rows it touched used to answer whether a guessed value matched.A dev reload now installs the kinds of signed-in person it reads only once it commits, and a kind's attributes may no longer be optional.
AccessControlContextand a field rule's arguments gain a requiredprincipal, so code that builds either by hand addsprincipal: null. - A plugin can now name a person it signs in on an audit row, and erase them when it deletes them.
More
ctx.audit.writetakesactorPrincipalandtargetPrincipal, by kind and id, kept in new columns of their own. Such a row keeps its network address, browser and metadata. A person's id inactorUserIdortargetUserIdis still stored as an erased account's, as before.ctx.principals.reportDeleted(person, { tx })erases a person from the audit trail in one transaction, the plugin's own when it passestx. Core clears the address and browser on every row the person acted on, and the plugin metadata on every plugin row naming them. It names the person on those rows by a keyed digest of kind and id, in place of the id, and keeps only that digest and when, in the newnextly_deleted_principalstable. Every row written about the person afterwards is written already erased. A report made inside an open transaction without passing it is refused.The digest's key is made once and kept sealed under
NEXTLY_SECRET. After a rotation, keep the old secret inNEXTLY_SECRET_PREVIOUSuntil the app has started once with both, as for plugin settings.Every audit row now records which kind of caller acted, in
actor_kind:staff,apiKey,core,anonymous, or the person's kind. Rows written before this release keepNULLthere and read as before.nextly migrateand boot add the new columns, indexes and table on all three databases, and rewrite no row.RequestActorgains theprincipaltype for a write made as a person. It carries the person's kind and id and noid, so the activity log, which records staff only, records no row for it. Webhook deliveries still report such a write assystem, as they did before. - A plugin's audit event naming a person of a kind no running plugin registers, a misspelled kind for instance, is now dropped with a warning, as one naming nobody is.
More
No deletion report could reach such a row, so the network address, browser and metadata it kept would have outlived the person.
The app now also refuses to start on a kind of signed-in person whose full name,
<plugin slug>:<name>, is longer than the 191 characters an audit row stores it in. On PostgreSQL and MySQL every audit write naming such a person failed at the column, and the row was lost with only a log line. - A plugin settings request made with an API key now counts once against the key's rate limit.
More
The settings route authenticated the key, then authenticated it again to check its permission, so each read or change of a plugin's settings spent two of the key's requests, and a key with one request left was refused with a 429. The permission is now judged on the caller the route already authenticated.
- A plugin can now sign in people who are not staff, such as a shop's customers or a site's members, without them ever reaching the admin.
More
It registers its own kind of signed-in person in
contributes.principals: a name, the attributes the kind exposes with their types, and a resolver that says who is signed in from the plugin's own session. Core names the kind<plugin>:<name>, and the app refuses to start on a kind whose name, attributes or resolver cannot be used, or whose name another plugin's kind already holds.Core calls a kind's resolver only where an entry point asks for that kind, and at most once per request. A plugin route asks with
principal: { kind }. Its handler gets the person asctx.principaland never staff: the route reads neither the staff session nor an API key, andctx.auth.currentUseranswers no user there. A route that requires the kind refuses a request with nobody of it with the same 401 any route gives an unauthenticated caller. A person's writes take the route's cross-site check as a staff session cookie's do, unless the request carries aBearertoken.principalcannot be combined withpublicorrequiredPermission. Server code asks withctx.principals.fromRequestornextly.principals.fromRequest, which answer nobody to a write from another site.nextly plugins infolists each plugin's kinds.The surface is
@experimentaluntil the feature is complete. - A plugin's update to an account core made for an externally verified person no longer fails when it carries the account's own address or verification date unchanged, as a full-object update does.
More
Before, the unchanged address was judged as a move to another address and refused with
FORBIDDEN, and the unchanged date was judged as a fresh verification, refused for the same reason and, on other accounts, restamped as vouched for by the plugin.An update that carries an account's address unchanged no longer writes it, so a stale update cannot put back an address another request has since moved the account from. An address stored in mixed case before addresses were lowercased is still rewritten in lowercase, so sign-in can find it. When an address moves, a verification date carried with it is recorded as the updater's vouching, not kept as whoever verified the old address.
The update schema now takes a verification date as a
Dateor an ISO 8601 date-time string, and refuses any other string withVALIDATION_ERROR. A string failed every update before, and"0"would have parsed as the year 2000.When a plugin's update is judged, a stored password counts as one when it is a hash core made, or, where the app passes its own
passwordHashertogetNextly, whatever that hasher stored.The plugin services documentation now says that
ctx.services.users.createrefuses a missing password withVALIDATION_ERRORonce an account exists, and withFORBIDDENon an empty install. - A write another site posts to a public plugin route with the session cookie is now recorded as a
csrf-failedaudit event naming the plugin and the route when the handler asksctx.auth.currentUserwho is signed in.More
The handler already got no user for such a write; the operator can now see the attempt, as they can when an authenticated route refuses one.
- When an app runs without the access-control service, a populated relationship now answers a signed-in person as a direct read does, from the person's permission grants, instead of returning every related row.
More
Whether the caller may read related users is now asked once per read, however many relationships point at users, rather than once per field.
An anonymous visitor no longer receives a populated user through a relationship: no visitor can read a user directly, and a user row carries the person's address. Visitors keep populated media, which public pages embed. A page your server renders with the Direct API reads as the system by default and still shows authors.
- A relation a plugin's or the app's table declares is now checked when the app starts, once every table is registered.
More
A relation to a Builder-made collection still resolves; one naming a table or column that does not exist stops the boot with
NEXTLY_SCHEMA_RELATION_UNRESOLVED, naming each such relation, instead of failing the first relational query with an internal error. In development it is reported once the boot's schema apply has run, without stopping the dev server. - A release now has a kind:
release, the default, orsingle, a document scheduled on its own.More
Pass
kindtonextly.releases.create()orPOST /api/releases, and read it on every release. A single-document release holds exactly one document. The database refuses a second one, even when two requests add documents to it at once, with a conflict that says to use an ordinary release instead.find(),count()andGET /api/releasestake akindfilter:releaseleaves out single-document releases andsinglelists only them. It filters on the stored kind, never on how many documents a release holds.nextly migrateadds thekindcolumn (existing releases read asrelease) and asingle_slotcolumn on release documents with its unique index. No row is rewritten.kindis experimental. - A release can be published now:
POST /api/releases/:id/publishandnextly.releases.publishNow({ id })run a draft or scheduled release at once, with the same checks as scheduling, by moving it to the current time (unless it is already due) and starting the job runner's releases run straight away, and answer the release as it then stands.More
When a releases run is already in progress, the release goes out with it (or with the next one) and the answer says it is still scheduled. Each release in a list also says whether the reader may publish it now (
can.publishNow). Both are experimental. The capabilities type,ReleaseCapabilities, is now exported fromnextly/schemas, and the admin reads its release controls from it.Behaviour change: a cancelled release is now final, like a published one. It can no longer be scheduled again, and documents can no longer be added to or removed from it; create a new release instead. A stopped release can still be fixed and scheduled again.
- Adding a Single to a release no longer creates its row.
More
The check that the document exists read the Single the ordinary way, which creates a Single's row the first time it is read, so a Single that was never saved was accepted, and a blank row was left behind even when the add was then refused. A Single that was never saved is now refused as not found, and nothing is written.
The check also always reads as the caller: a Direct API instance's default API key can no longer stand in for a caller who brought no key of its own.
- A release now checks a document the same way whichever way it is added.
More
The Direct API's
addMemberused to skip what the REST route checked, so a server caller could add a document that does not exist, one in a collection without drafts, or a Single by an id that is not its own. Those are now refused from every caller, with the same errors as over REST, and a Single is recorded by its own stored row. A trusted call (overrideAccess, the default) skips only the read permission.Finding the releases that are due is now one query on the release's state and time, instead of reading every scheduled release and filtering in memory, so a long list of future releases no longer slows each pass of the drain.
- A Single created in the admin can be added to a release from any server process.
More
The release target check looked for the Single's row without first registering its table in the process handling the request, so a worker that booted before the Single existed refused a valid add with an error. It now resolves the Single the way every Single read does.
- A release moves only from the state it was judged in.
More
Every change to a release (scheduling, cancelling, stopping, publishing, and adding or removing a document) now advances its revision, and scheduling compares against the one it read: a document removed while a release is being scheduled makes the schedule refuse, rather than leaving a single-document release scheduled with nothing. A release's capabilities now say what a single-document release allows: no scheduling while it is empty, no second document, and no removing its document once scheduled.
For code calling
ReleasesRepositorydirectly:scheduleRelease'sexpectedRevision(added as optional with Publish now) is now required, andfromno longer has a default. A schedule fenced only on state cannot tell that a release changed under it, so every scheduling move must name the revision it read: read the release, then pass itsrevision. - Adding Nextly to an existing app no longer writes a media or health route where a route handler the app already has answers that URL through a dynamic segment, such as
app/api/[...rest]/route.tsorapp/api/[name]/route.ts: the new, more specific route would have taken those requests from it.More
The route is reported, with its content to add, instead. Pages reached through a dynamic segment, such as a site's catch-all page, still leave the routes free to be written.
- A project from
create-nextly-appwhose config registers a plugin, such as the blog with its newsletter form, now installs that plugin in itsbuildscript, afternextly migrateand beforenext build.More
A production boot refuses a configured plugin that was never installed on its database, so a fresh blog project built for production answered every request with a 500 until
nextly plugins installhad been run there by hand. Installing again on each build is safe; each run adds one row tonextly_schema_events. - A plugin's entity transform can no longer change what isn't its to change.
More
A transform hands back a whole new definition of a collection, Single or component, so nothing stopped it from setting an option the entity's own definition is refused for, such as a collection's
db.idTypeof"serial", or from dropping another plugin's column. Each transformed entity is now validated as its definition would be, and a refusal names the plugin.A plugin transforming another plugin's entity must now list that plugin in
dependsOn, so the owner can't be uninstalled from under the change. It can't move the entity to another table, and it can't remove a field, or change how one is stored, unless it declared that field. An access rule, hook, validator or admin setting a third plugin contributed stays unless the transforming plugin depends on that plugin too. A plugin that transformed another's collection without declaring the dependency stops at boot until it does. - A two-factor challenge is answered once on every instance.
More
The first correct answer now marks its sign-in answered in the database, in a new table
nextly_spent_challenges, by one write only one caller can win, committed before the session is issued. Any later answer to the same pending token is refused, on whichever instance it reaches, before the challenge'sresolveruns, and two correct answers at once mint one session. This holds whateverrateLimit.storeis set to. The one-shot mark used to live in the rate-limit store, so with the default memory store an answered challenge could be answered again on another instance. A challenge pending across the upgrade is answered against the new mark.The attempt budget is counted in the limit store
rateLimit.storeselects, so with"database"wrong answers on every instance spend one budget. An app's own store of the earlierRateLimitStorecontract keeps serving it. Its keys, which name an account, are stored only as keyed hashes, so a budget pending across the upgrade starts again. When the store can't be reached, or doesn't answer in time, a two-factor answer is refused with "Please try again shortly." (503) and no session is minted. The boot warning about a per-instance budget no longer appears when the database store is selected orrateLimit.singleInstanceis set. Expired marks are removed as new ones are written, an hour after their token's end, so an answer let through before the end always meets them.AuthRouterDepsgains two optional members,spentChallengesandlimitStore, so deps assembled by hand keep working: withoutspentChallenges, the mark stays in the rate-limit store as before. While an app's own store of the earlier contract is configured, the earlier release's mark is also kept there, so during a rolling upgrade instances of either release refuse a challenge the other settled.nextly/authexportsdatabaseSpentChallengesandSpentChallengesfor such a caller to opt into the database's marks. - Two public types now match what Nextly accepts and returns.
More
Code that calls them compiles as before. A hand-written stand-in for the
Nextlyinterface, such as a test double, must now returntokenandexpfromlogin, as the real one always has. - A job defined with a typed input,defineJob<{ cursor: string }>(...), now goes into the config'sjobslist, or a plugin'scontributes.jobs, without a cast. Both lists now take any job, whatever input its handler takes. A value that is not a job is still refused, and a handler is still typed for its own input only, so it can't be called with another job's input. -login()on theNextlyinstance fromgetNextly()is now typed asLoginResult, the user with the sessiontokenand its expiryexp, which is what it already returned. The documentedresult.tokennow type-checks. - A comparison no longer offers a withdrawn version as what is published.
More
The
publishedbaseline, and the comparison that opens on the current draft against what is published, now apply only while the language (or the part of the document with no language) is actually served: after an unpublish, or while a translation has never been published,publishedis refused withNO_BASELINEand the default opens on the version before. Whether a language is live is now decided in one place for every path, from the main row's status and the translation's own, and a translation's own status is read through one shared reader. - The accounts plugin names the account on every email it sends, so core erases the email's delivery record with the account when the account is reported deleted.
- The accounts plugin runs on core's own helpers: its password, one-purpose secret, opaque secret and limiter types come from the plugin SDK, and it declares its limit rules to core, which checks each when the site starts.
More
It now refuses to start on a core that does not lend every helper it is built on, and, in production, on limit counts each instance keeps apart.
- Add
@nextlyhq/plugin-accounts, site accounts for people who are not staff: sign-up with address verification, sign-in with per-address and per-client waits and known browsers, password reset, the holder's password and address changes, account deletion, the account kind of signed-in person, and REST endpoints for each, built on the account helpers core lends plugins. - The accounts plugin reports each account it deletes to core, in the transaction that deletes it, so core erases the account from the audit trail with the deletion.
More
Staff actions name the account as the audit row's target person rather than in its metadata, and the plugin refuses to start on a core that does not lend it a way to report deletions.
- The accounts plugin's stand-in limiter gives a count back in the run the caller's reservation names for it, as core does, so a copy carrying stale runs gives nothing back.
- The accounts plugin's testing stand-ins keep core's contract: the limiter gives a count back only to the run it was counted in and refuses a malformed release before it reports a store outage, and one-purpose secrets never mint a code that repeats the live one it replaces.
- Adding Nextly to an existing Next.js app now installs everything it needs and finishes, on npm, pnpm, Yarn and Bun.
More
The app gets
esbuildas its own dev dependency, sonextly.config.tscan be read and code-first collections load; before, npm left it nested under another package where Nextly could not find it. The install uses the package manager the app's lockfile names rather than the one that launched the generator, sonpx create-nextly-appin a pnpm app no longer runs npm over pnpm's install.On pnpm and Bun, the install scripts of the native packages Nextly brings (the SQLite driver, esbuild and sharp) are approved before the install, in the one place the app's own version reads them and at its workspace root in a monorepo:
permissionswhenpnpm-workspace.yamlhas that section (pnpm 12.11+),allowBuilds(pnpm 10.26+),onlyBuiltDependencies(pnpm 10.6 to 10.25),pnpm.onlyBuiltDependenciesinpackage.json(pnpm 10.0 to 10.5), and Bun'strustedDependencieswhen the app keeps that list. pnpm refused the install until then and the generator stopped with no config or routes written; Bun skipped the SQLite driver's install script and the app answered 500. Only Nextly's packages change: any approval or denial the app already wrote, version-scoped ones included, is kept, pnpm's own placeholder is replaced, and each change is printed. Anesbuildthe app already declares is left as it is. A project set up this way with an earlier version can addesbuildas a dev dependency and approve these packages the same way, then install again.When an install fails, the generator now names the exact command to run to see why.
- An admin page left open while the app's root layout is edited during
next devno longer fills the console with "Cannot read properties of null (reading 'removeChild')".More
The admin used to remove the host app's favicon links, which React owns, to show its own; it now switches them off and back without removing them, so React can keep updating
<head>. - The admin no longer writes its theme onto the host app's
<html>, so an app whose root layout is the onecreate-next-appwrites no longer logs a hydration mismatch on every admin page.More
The theme, light, dark or system, is applied to the admin's own containers, its dialogs and menus included, before the first paint, so a dark admin no longer flashes light on load. Native controls such as scrollbars and inputs follow the admin's theme.
ThemeProvideranduseThemefrom@nextlyhq/adminkeep their names and thenextly-themestorage key, and aThemeProviderinside another still defers to the outer one.ThemeProvidernow acceptsdefaultTheme,storageKey,enableSystem,forcedThemeandnonce; next-themes' other props, such asattributeanddisableTransitionOnChange, are no longer accepted, since the admin no longer depends on next-themes.useThemereturnstheme,setTheme,resolvedTheme,systemTheme,themesandforcedThemeas before.A site with a nonce-based Content Security Policy passes its nonce to
RootLayout(<RootLayout nonce={nonce} />) or toThemeProvider, so the admin's small pre-paint script is allowed to run. @nextlyhq/adminexportsThemePaintScriptand theThemeProviderPropsandUseThemePropstypes.More
A host that puts
ThemeProvideraround admin content of its own renders<ThemePaintScript />first inside its.nextly-adminelement, so a saved dark theme shows from the first paint. WithenableSystem={false}and nodefaultTheme, the admin now defaults to light rather than following the system setting.- The admin's create-user and edit-user forms now accept exactly the passwords the server accepts.
More
Any symbol counts, not only
@$!%*?&#., and a password over 128 characters is refused before it is sent. Every rule a password misses is listed, not only the first. Choosing "Set a password now" for a new user now says that they choose a new password the first time they sign in, which the server already requires.Setting a user's password through an update (the admin's edit form, the REST route or the Direct API's
users.update) now stores the password exactly as given. It used to trim spaces from its ends before hashing, while account creation and sign-in did not, so a password whose symbol was a leading or trailing space could not be used to sign in. The admin's edit form no longer trims it either. A value of only whitespace still leaves the password unchanged. Existing password hashes are unaffected: only passwords written from now on change. nextly/confignow exports the dashboard's widths (WIDGET_WIDTHS,isWidgetWidth,widthFromSizeand theWidgetWidthtype), and the admin reads them instead of keeping its own copy.- The
afterDrizzleescape hatch's refusals now point at what to use instead, and the documentation lists every construct it refuses.More
A check constraint or a foreign key returned by the hook is refused with a pointer to
checksorforeignKeysondefineTable, an expression index toindexes, and an enum column tocol.enum(). A partial index's refusal no longer promises a later release: partial indexes are refused on every dialect, because MySQL cannot build one. - The AGENTS.md a new project or plugin receives names only exports that exist:
definePlugincomes from@nextlyhq/plugin-sdk, not its/adminentry, and every sample imports the functions it uses.More
The generated-types stub no longer mentions a
nextly devcommand. - In an existing
.env.example, a credential is flagged when its first word only begins like a placeholder word (enterprise-…), and when the database URL carries it in a query parameter whose name is percent-encoded (?p%61ssword=…). - The
nextly.config.tswritten when Nextly is added to an existing app keeps its placeholder comments where they apply:// Add your collections heredirectly abovecollections, and// Add your singles (globals) heredirectly abovesingles, inside the exported config.More
Both used to run together on one line above
export default. - Adding Nextly to an existing app no longer writes through a folder on a file's path that is a symbolic link.
More
When the app's
app/admin,types/generatedor another folder Nextly writes into is a link to a directory elsewhere, nothing is written there, which could have been outside the project. The file is reported as left alone, with the folder that is a link and, for the admin's files, the content Nextly needs, as the media routes already did. - Adding Nextly to an existing Next.js app now writes the admin's media route under
/admin, the same file a new project gets, so uploads, edits and deletes in the admin's media library work.More
Every request to it is checked against the signed-in caller's media permissions; the public media route stays read-only. The routes import the Nextly config by a path relative to themselves, so no import alias is needed. A media or health route the app already has is left exactly as it was and reported, and for the admin's route the report includes the content to add. A route is not written where a Pages Router page answers its URL (in
pages/orsrc/pages/), since Next.js refuses to build a page and an app route at one URL; that is reported in the same way. - Adding Nextly to an existing app no longer replaces or refuses over files the app already has, and the run ends with one report of everything it did.
More
Before, a
nextly.config.ts, admin API route or admin page already there stopped the whole run with an error, and the admin'slayout.tsxwas written over whatever the app had.Now every file is created only where nothing is (exclusively, so a link or a file that appears meanwhile is never replaced). One already there is left byte for byte, and the report says so, with why and, for the admin's routes, page and layout, the content Nextly needs there. The report lists each file written, each extended with what was added (the Next config's packages included), and each left alone, across every step, the env files and their warnings included, in one place after the spinner, by path, key and package name only: no env value, the generated secret included, is ever printed.
- A role given with an expiry date now stops granting anything once that date passes.
More
Until now the expiry was ignored almost everywhere: an expired role kept its permissions in every check, an expired super-admin assignment kept super-admin authority, and login and session refresh still put the role in the session's claims. Cached permission answers, super-admin answers and API key grants now also end no later than the expiry of a role they came from, and a session's access token ends no later than the first of its roles to lapse, so the client refreshes into claims without it.
Assigning a role whose earlier assignment has expired now gives it again, instead of answering that it is already assigned. Listing a user's roles shows only the roles in force. If a user's roles cannot be read at login or refresh, the session is refused rather than issued with no roles.
Permission answers cached in the database before this release are no longer read: they could have come from a role that has since expired. They expire and are cleared as usual, and the first check of each permission after the upgrade is worked out afresh.
Breaking, for code that builds its own
AuthRouterDepsforrouteAuthRequest(nextly/auth, experimental): thefetchRoleIds(userId)dependency is replaced byfetchSessionRoles(userId), which returns{ roleIds, validUntil }(theSessionRolestype, now exported): the role ids in force, and the soonest expiry among them in epoch milliseconds, orundefinedwhen none expires. Read only assignments whoseexpires_atis empty or later than now. The access token is bounded byvalidUntil. - Moving a block into a component instance with Alt+ArrowRight no longer makes it vanish.
More
A component instance is not a registered block, so the keyboard move could not read its slots and filed the block under the engine's default slot, which an instance draws only when its component exposes it. The move now reads the slots the instance's component exposes and places the block in one of them, and offers no move when the component exposes several and none is the default.
- Moving a block into the container above it with Alt+ArrowRight no longer makes it vanish.
More
The keyboard move filed the block under a slot name of its own that no core container renders, so the block stayed in the page's data, selected in the inspector and listed in the breadcrumb, while the canvas showed nothing. It now lands in the default slot every core container declares and renders.
Each row of the Layers panel also names the block it stands for in a
data-nx-layerattribute, as the canvas already does withdata-nx-node. Row labels repeat (every paragraph is "Text"), so a test or an integration had no stable way to find one block's row. - A job written inline in the config's
jobslist or a plugin'scontributes.jobs, as an object rather than throughdefineJob, keepsJobInputas the type of its handler's input, alongside jobs defined with a typed input, which fit those lists without a cast. - Back and Forward with unsaved changes now ask before leaving in a real browser.
More
In Chromium the editor's guard heard the history move only after the app's router had already drawn the next page, so pressing Back on a dashboard in customise mode, an entry or a single with unsaved changes left without asking and lost the changes. The guard now listens from the moment the admin loads, ahead of any router.
- In a new blog project, clearing the search box and typing a new query no longer shows the old query's results while the new search loads, and "No results" waits until the search has answered.
- In development, a boot that cannot apply its schema, and the check that every declared relation resolves before plugins initialise, now log the error with its code, its cause chain and its log context, through the same description every other operator log uses.
More
Before, they printed the message alone, so a refused relation's report left out the code and the relations it named.
- A Schema Builder relationship's foreign key works for a camelCase field, a long name, and a relationship an older save stored.
More
On SQLite, a relationship named in camelCase (
leadAuthor, stored inlead_author) gave the table drizzle-kit rebuilds a key over a missing column. A long collection and field name made a key name MySQL refuses past 64 characters; it is now shortened with a hash past 63, as other derived names are. And a required relationship that nulls on delete, stored by an older save, no longer refuses every later save of its collection. - Admin: a schema-builder page is no longer shown before the server has said the builder is on.
More
Opening a builder address where the builder is off now goes to the dashboard without the editor appearing first, and a failed request shows a message instead of the builder.
- A Schema Builder one-to-one relationship stays unique when a save changes its table, and a SQLite save no longer drops a table's triggers.
More
Adding a relationship to a Builder collection, or changing a field's type on SQLite, rebuilt or altered the table in a way that dropped the UNIQUE the Builder gives a one-to-one relationship's column, on SQLite and PostgreSQL, so the column then accepted duplicates. While a table held such a column, a SQLite save of another collection could also be refused. Both now keep the constraint. A SQLite save that would rebuild a table with triggers on it is refused, naming the table, instead of dropping the triggers; make that change with a migration that recreates them.
- A Schema Builder collection keeps its foreign keys, and gets the key of a relationship added to it.
More
The Builder creates a collection's table with each relationship's key, but every later save went through the schema push, which described no keys. So a relationship added to an existing collection got no key on any database. On SQLite, where a key lives only in the table's definition, a save that changed a field's type rebuilt the table without its key, and a save that rebuilt a collection other collections point at failed outright. A save now adds the key of a relationship it adds, and keeps every key a table already holds through a rebuild, with no delete action running during it. A relationship that was added before this and has no key is left as it is.
- A repeater or group on a table the Schema Builder creates is now stored in the database's JSON type (
jsonbon PostgreSQL,jsonon MySQL), as on every code-first table.More
That covers Builder collections and singles, and the singles
nextly db:synccreates itself. They were created astext, while the schemadb:synccompares against names the JSON type, so each such table was offered a conversion its author never asked for. Tables created from now on match.Existing tables keep their
textcolumns: this changes no stored data, anddb:syncstill offers to convert them. That conversion succeeds when every stored value is valid JSON. It refuses the whole apply when one is not (an empty string, for example), leaving the column as it was. SQLite is unchanged: it stores JSON as text either way. - New command:
nextly users:clear-plaintext-passwords.More
Earlier releases stored some passwords as typed instead of hashing them (32 characters or more containing a colon, given when an account was created, or any value sent to
PATCH /api/users/:id/password). Such a value never let anyone sign in, but it is readable in the database and its backups.Run the command once the upgraded version serves every instance, since an old instance can still store a password as typed. Without flags it only counts the affected accounts; with
--apply --confirm-default-hasherit clears each stored value and ends that account's sessions, printing only a count: none can be renewed, and an access token already issued lasts until it expires, within fifteen minutes by default.--applyalone changes nothing: the second flag is your statement that no stored password was written by a custompasswordHasher, now or earlier, since the command cannot see one and would take its hashes for plain text. Values in a format other hashers write (an argon2, scrypt or pbkdf2 string in the modular-crypt form their libraries write, or a hex salt and digest joined by a colon) are never counted or cleared; a value that only starts with$is.nextly migratenow prints the same count and names the command, and changes nothing itself. People whose password was cleared are refused at sign-in exactly as before, and set a new password with Forgot password, or an administrator sets one. nextly/configno longer pullsnode:cryptointo a browser bundle.More
It published three dashboard layout limits from a module that also builds a hash with
node:crypto; the limits now come from a module with no imports, and keep their values.Every entry listed as safe for a browser is now checked: a test walks each one's runtime imports and fails, naming the entry and the path, if any reaches a Node built-in, a database package or a module marked with
server-only. The database-operator re-exports, the table DSL and the stored-bytes helper reach server-only code by design and are imported only from server code, so they are listed as server entries; this changes no build output.- A collection's
dboptions are now checked where the collection is defined.More
Before, an
idTypethe write path did not recognise gave UUIDv4 ids in silence, and anallowIdOnCreatethat was nottruediscarded every id a caller supplied, and any other name indbwas not read at all. Now anidTypeother thanuuidoruuidv7, anallowIdOnCreatethat is not a boolean, a name that is not an option (a misspelling such asidTyp), anddboptions that are not an object are refused, naming the option.serial, which a config carried over from Payload may hold, is refused with its own message: a collection's id is text, which every relation and API path reads. A sequential number, such as an order number, goes on a table keyed bycol.serial(). @nextlyhq/plugin-commerce's checkout, still@experimentaland unpublished, refuses a line past what its item now allows and an order whose jobs went stale while the catalogue was read, retries a payment's start only with the provider it began with, holds catalogue names and SKUs to 255 characters, and keeps provider references that differ only in case apart on every database.@nextlyhq/plugin-commercegains the plugin itself,commerce(), still@experimentaland unpublished: its own tables and migrations, a built-in products collection to sell from or a catalogue source of the site's own, and a cart kept on the server behind an HTTP-only cookie, with REST routes and typed server functions for a storefront.More
Catalogue sources can report items sold to order, which hold no stock and are capped per line.
@nextlyhq/plugin-commercegains checkout, still@experimentaland unpublished: a cart is quoted from the catalogue's own prices and completed into one numbered order, with its stock held while it is unpaid and its payment started through the store's payment provider, never twice, even when a request is repeated or the provider is down.More
Checkout takes no orders until commerce's jobs have run, which is a launch step. Stock and order records keep catalogue references apart exactly, letter case included, on every database.
- A comparison can start from the version you last looked at.
More
A client marks a version as seen with
PUT …/versions/{n}/seen(people only); the admin doesn't send it yet. API keys and the system are refused, and no read records anything. Marks count by visit: a mark more than 30 minutes after a marker's last one starts a new visit, and "last looked at" is the furthest version of the previous visit, kept per person, document and language. Thelast-viewedbaseline compares from it, falling back to the nearest older version when retention removed it and naming the one removed, and the history list marks the row where you last looked. Markers live in a new core table,nextly_version_views, whichnextly migratecreates, and go with their document, collection, Single or person. - A version comparison now names fields the way the editor does.
More
Each field carries the label it declares (
declaredLabel, ornullwhen it declares none, so a reader applies its own naming rule instead of showing the field's key) and its place among containers (path), including groups with no name, whose heading was lost before. A field the reader may not read is reported in its place ashidden, with nothing of its values, instead of disappearing. Keys with no field are markedsystem(a framework column such as when the document was first published) orremoved, and removed fields are listed last. A comparison with withheld fields is markedincomplete, so "no changes" then means only that nothing the reader may read changed; a changes-only comparison never lists a hidden field. The new response fields are experimental.Compatibility:
FieldDiffgains thehiddenmember. Code that switches overkindexhaustively needs ahiddencase (or a default) after upgrading; the admin draws it as "Hidden: you don't have access to this field". - A version comparison can start from a baseline the server works out, instead of two version numbers:
?to=6&baseline=previouscompares version 6 with the version before it in the same language,publishedwith that language's latest published version, anddraftwith its current draft.More
With neither a
fromnor abaseline, a comparison opens on the current draft against what is published when both exist, and otherwise on a version against the one before it. A language's first version is refused withNO_BASELINE, and the response names the baseline it used. Core features can register their own baselines. Comparing two named versions works as before. Baselines are experimental. - A many-to-many relation inside a component was stored as a column on the component's row, but the schema planner did not expect that column.
More
A push over such a component could plan to drop the column that stores the link. The planner now expects the same column the component table is created with, so a push, migration, or introspection finds no difference.
- A page builder document is now judged as a reader receives it, on every write path — the editor, the API, a batch, and an import that skips hooks: - a component save that would make the component contain itself is refused, naming the chain; - a document whose components put a block where the nesting rules forbid, or nest deeper than the depth limit counted from the top of the document, is refused, naming the placement; - a component change that would break a page, Layout or component placing it is refused, saying how many documents it would break and which rule.
More
The documents themselves are named only in the server log, since they were found and read as the system. Documents the usage index cannot vouch for are reported in the server log as unjudged.
Plugin field types gain an experimental
writeargument onvalidate: which entry the write is for, andreadStored, a read of stored values of the validator's own type on the write's own connection — so a batch's own rows are seen. The page builder's former cycle-guardbeforeChangehook is removed; bulk component writes with hooks running are no longer refused for being in a transaction. - Under
next dev, a saved config that imports a file in a folder that does not exist yet now applies once that folder and file are created.More
Before, the import was seen only on the next save of the config.
> Notes truncated to stay within GitHub's release body limit. The complete changelog for every package is in its CHANGELOG.md at v0.0.2-alpha.67.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/blocks-react@nextlyhq/builder@nextlyhq/eslint-plugin@nextlyhq/plugin-form-builder@nextlyhq/plugin-mcp@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
Earlier releases
Releases before 0.0.2-alpha.66 are listed in Nextly's changelog on GitHub.