0.0.2-alpha.66

  • Saving a published page no longer puts the edit live. Pages now keep a working draft.
  • Every sortable table in the admin can be reordered from the keyboard, and a screen reader is told what is being moved.

Before you upgrade0

These notes list no steps.

What's changed64

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

Patch Changes

  • The schema builder's drag announcements name every field, and its row ids are only the ones the list mints.
    More

    A field just added to the canvas has no label and no name until its author fills them in, and it drags in that state: it was announced as nothing at all ("Picked up , row 2") and, beside a named field, as "and Title". It is now called "an unnamed field", on the announcement and on the nested drag handle alike. A row id that is not row- followed by a canonical nonnegative integer no longer names a row, so a drop the reorder would refuse is no longer announced as a move.

  • A dashboard card asserted a meaning its own data did not have.
    More

    The status breakdown was generated for any collection carrying a field NAMED status, and the schema permits an ordinary field of that name with the publishing lifecycle switched off — so such a collection got a card titled "by status", describing the split "between draft and published", over a column holding whatever that author's field holds. It is now gated on the lifecycle capability itself, which is what the health card beside it already read.

    A chart's placeholder rows were told apart by styling alone. A bucket holding no value and one holding the literal text (empty) were drawn in different colours and announced identically, so the readers who most needed the distinction were the ones without it. A placeholder now says what it is — "no value stored" — rather than relying on italics. Exact separation is not achievable, because no string can be reserved from a column of arbitrary text; what is fixed is that the placeholder describes itself instead of depending on something a screen reader never sees.

  • The collections and singles cards no longer decide for themselves whether they have anything to show.
    More

    A fresh dashboard made the same "get started" pitch three times: the setup checklist, the demo-content offer, and a third panel the collections card drew on its own when it had no counts. The singles card went the other way and returned nothing at all when the install had no singles -- and a card that renders nothing still holds its place in the grid, so the layout reserved an empty slot on every install that never used them.

    Both cards now declare a condition, and the host offers them only while it holds. Two names join the closed set a conditional widget may use: collections:present and singles:present, each true while this reader may read at least one. They are about what exists rather than what is in it, so a collection with no entries yet still counts as present -- it is still something to list -- which is what separates them from content:empty. The GetStartedEmptyState panel is removed; the checklist and the demo offer, which the host withdraws on their own, are the two pitches a new reader meets.

  • The dashboard's setup checklist is answered by the server now, and shown only while there is something left to do.
    More

    It used to derive its own steps in the browser from dashboard statistics, and decide for itself whether to appear by reading a dismissal out of localStorage. Both were wrong in the same direction. The steps were computed in two places from the same four counts, so they could drift; and dismissal was per BROWSER, which answers a question about a person — the same admin on a second machine met a checklist they had already finished, and a colleague met one reporting someone else's progress.

    The host answers both. It reports which steps this reader has finished, and the widget condition deciding whether the card is offered is DERIVED from that same answer — so a card showing every row ticked, and a card that will not go away, are both unreachable.

    Steps are detected rather than self-reported: nothing is ticked by hand, the install is asked. Only what can be answered about the reader is included, so an editor is never held short of finishing by content they are not allowed to see. The first step is complete for everyone, and truthfully so — reaching the card means an account exists and they made it. A checklist that opens above zero is finished far more often than one that opens empty, and that head start is worth having only if it is true.

    A transient card can now name SEVERAL conditions, and is offered only while every one of them holds.

    The get-started card needed two, and neither answers alone: is there nothing to look at, and is the offer of demo content still open. Declining that offer creates no content, so the card kept its slot on the strength of the install still being empty — visible, drawing nothing, for a reader who had already said no. The two conditions are scoped differently on purpose: whether there is content to see is about the reader, while whether a project took the demo data is recorded once for the project, so a second admin is not offered it again after the first declined.

    The dashboard's last browser-stored dismissal is gone with it. A hook that derived onboarding steps in the browser and remembered dismissal in localStorage had no consumer left once the host started answering, and the types beside it described a checklist that no longer exists — including a step vocabulary listing five names none of which are detected any more.

  • An exposure row reports itself cleared when the winning clear erases its target — at its own path or an ancestor's, by any exposure — never when the clear is below it, and a cleared row's value is always absent rather than the clear sentinel.
  • The insert panel offers the site's components beside its blocks and patterns, and placing one writes a single instance node that keeps pointing at the definition — nothing is copied into the page.
    More

    Component definitions now reach the editor through a route of their own, GET …/library/components, gated by the components collection's read permission and answering the canonical { items, meta } envelope. It lists every lifecycle state the caller may read, so a never-published component can be placed on a draft page, and reads each definition by id AS THE USER so an author who may edit a component sees its working draft while one who may only read it sees the live definition. The canvas and the entry form's resting miniature both resolve instances against them, so a placed component renders in the builder — and stays rendered after Done — rather than as a could-not-be-loaded placeholder; both wait for the read and say when it failed, with a way to try again. A definition whose own root is another component is judged for placement by what that component draws, under the site's own document caps, and the panel says when a tier of the library was too large to load whole or could not be read.

    The component tier is read in the language the surrounding document is being edited in, as the public page reads it, so a localized component draws on the canvas as it will on the page; and a row without a title — a custom collection with none, or one field-level access redacts — is offered labelled by its id rather than left out.

    The Direct API's findByID now forwards status, so an untrusted by-id read can reach a row that was never published — a caller passing it before was silently ignored. A plugin route's caller gains identity(), which resolves once per request the identity an access rule is evaluated against — the user with their roles, and an API key's scope — so a route reading through the Direct API on the caller's behalf reads as the caller its own gate admitted; PluginRouteIdentity names its answer in the SDK.

  • A component can no longer be saved into a shape where the library references itself.
    More

    Components may place other components, so the library is a directed graph. A loop in it is not a crash — the renderer detects one and draws what it reached — but it leaves a gap where the loop closes, on every page placing anything on the loop, and nothing said how it got there. The editor already withheld the tiles that would close one, but that is a snapshot: an author saving against a library read that had since gone stale closed a loop anyway.

    The write now refuses, naming the chain to break — Hero → Banner → Hero — rather than leaving the author to find which placement did it. The check reads each referenced component as it currently stands, and refuses rather than guessing when it cannot read them all. A component the saving author cannot read is named only as …, so a refusal does not hand out identifiers.

    What it does NOT close is a chain that mixes lifecycle states. The published walk reads every state, so a component that has never been published can join a chain the public renderer cannot draw — it could not load that row — and a save is then refused for a loop no reader sees. Reading only public states is not available to a plugin: naming the state matches nothing on a site whose workflow calls its public state something else, and dropping the system-level read would hide a component the author cannot see, which is a MISSED loop rather than a false one. So it over-refuses, which is the recoverable direction.

    What it does NOT close is a loop that only an EXISTING placement of the saved component completes. A page or component already placing it may carry an override keyed for an exposure it does not yet declare, which is inert; declaring that exposure activates the override and can point a nested node back at the placer. Measured on the renderer: the same library composes cleanly before the exposure is declared and reports a cycle after, while the saved component judged on its own composes cleanly either way. Seeing it means asking which components place this one — the reverse direction, which neither the walk nor the composition of a single subject can supply.

    What it does NOT close is two authors closing a loop between them at the SAME moment. The check runs before its own write commits and takes no lock the other write contends for, and a plugin hook has no transaction to enlist in — so two saves that each read the other's document before either commits are both approved. Closing that needs a boundary the two writes share.

    A component's VARIANTS and its PLACEMENTS count as references. A variant may preset an exposed componentId, and a placement may override one on the component it places — so a definition whose stored ids look harmless can still resolve back to itself. Both the insert panel and the write judge a definition by what it can reach that way, so the editor does not offer a component whose insert the save would refuse.

    What decides a refusal is the RENDERER, not that scan. Overrides flow down through nesting, so a placement can re-point a node two levels below it: the scan is short by a level wherever that happens, and long by a stored edge wherever an override points one away from the loop. Both directions are real, so a save is judged by composing it with the same function that draws the page — the only answer guaranteed to match what a reader would see. The scan still runs first, because it is what can name the chain to break; it no longer has a vote.

    That is asked once per variant the component offers, and once for a placement naming none. Only one variant resolves at a time, so a loop that exists under one selection is real, and a scan that unions them all would refuse a component whose every selection is fine.

    A component that names ITSELF is refused from the submitted document alone, without reading the library at all — nothing in it can reopen an edge the document has already closed, and the reads it skips each run the site's hooks.

    A limit the composition reaches is not a report that there is no loop. Where a chain runs deeper than components may nest, the renderer stops before the loop closes and has seen nothing past that point — so the save is refused on the scan's own chain rather than approved on the renderer's silence. The same holds for a library it could not finish reading.

    Clearing a component's content is not the same as leaving it alone. A publish that also empties the field stores nothing, so it can close no loop, and it is allowed — where previously the accumulated draft was judged instead and the save was refused for a chain that very write removes.

    It judges the lifecycle form the write actually changes. An ordinary editor save is stored as a working draft and leaves the published row alone, so it is checked against what a preview would show; publishing checks the live library as well. Publishing an accumulated draft is checked too, even when the request carries nothing but the new status — that is the write that brings the draft's content live.

    It is deliberately a refusal rather than a warning: the author who closes a loop is not the person who sees the gap, so a notice would go to someone who has no reason to act on it.

  • A component the editor offers, and one the canvas draws, are now judged against the same forest and read from the same row.
    More

    A saved pattern keeps the instance nodes it held, so a pattern that places a component is a second way to copy that component into itself; editing a component now withholds those patterns by the same graph the component tier is filtered by. A pattern's nested instances are judged by what they DRAW rather than by the reserved instance type, which a parent rule read as unrestricted and a slot's admissions list refused outright — so a component drawing exactly what a slot asks for could not be placed in it, and one whose root belongs elsewhere could.

    The roots query follows a nested instance's own overrides, so a component whose only root that instance hides is no longer offered as placing something; and it survives a definition whose fields throw rather than taking the insert panel down with it.

    The library is paged from the last row seen rather than from an offset, so a component inserted or deleted by another author while the editor loads it can no longer be skipped and reported as a complete library. A completed component must be the row the listing named and must have been read whole: a row answering under a different id, or one whose document field an access rule removed, is left out and the tier reported cut rather than served under another component's name. Its keywords travel, so the palette can find it by them, and a store the plugin was told about is gated by the slug it actually reads.

    The entry form's resting miniature reads the component library only when its page places a component, instead of on every mount of every blocks field.

    An exposed visibility control now reflects a gate the component itself carries, rather than reporting a node as shown on a page the renderer withholds it from.

  • A dashboard could count and group entries but had no way to show either as a picture.
    More

    Every chart meant writing a React component, which is how a dashboard stops looking like one product.

    Two archetypes now draw from a query the way metric and table already do, so an author declares a chart instead of shipping code: bars compares the buckets of a groupBy, and timeseries plots the points of a window.

    They are drawn here rather than by a charting dependency. The popular one pulls eleven packages — a state-management stack in every install's admin bundle for two shapes — and renders role="application", which puts a screen reader into forms mode over a graphic that has no keyboard interface. Owning the markup is what makes the right pattern reachable: each chart is a labelled graphic with a text alternative naming what it shows, beside the same numbers as a real table.

    The bars run horizontally because their labels are whatever the grouped column holds — author names, tags, statuses — and a column chart has only its own width for those, which forces rotation or truncation. A capped bucket set says so rather than presenting a partial comparison as the whole one, and a timeseries labels each point in UTC, the zone its buckets were computed in, so the axis cannot name a different day than the server did.

    Every collection that can answer them now generates the two cards, beside the count, list and table it already generated — so the charts are something an install has rather than something a plugin author could build. A timeline is withheld from a collection whose only dates the read cannot bucket, and a status breakdown from one with no status column, because offering a card whose query is then refused is a card that draws an error on every load.

  • A function default cannot read a field the caller may not write.
    More

    A defaultValue written as a function receives the data built so far, and that data still held every value the caller sent, including one a field rule denies them, so a default could read the forbidden value and carry it into a field the caller IS allowed to write: the denied field was stripped and its value persisted anyway, one column across. The rules are now applied to a COPY, and the copy is what the functions read. The record itself is left alone until the pass that decides what is stored, because a rule may depend on a sibling the caller omitted precisely BECAUSE it has a default, and judging that rule before the defaults exist would deny it and lose the value for good.

    That copy is now defaulted and judged a second time before the record reads it. A field the caller may not write still takes its own declared default, and the pass that runs before the defaults has no key to judge because nothing supplied one; left in the copy, a later function default read it and carried it into a field the caller CAN write, so the rule was defeated one column across by the field's own default rather than by the caller's input.

    A config reload installs the live config's field functions, replacing them wholesale at the point the reload commits. Replacing rather than adding, so an entity the new config no longer declares stops deciding anything: a collection dropped from the config keeps its registry row and its table so an orphan sweep can find them, so it stays addressable. At the commit point rather than when the config is read, so a reload whose DDL succeeds and whose later sync fails does not leave a rule from a config the process refused deciding writes. The field-level registry holds every field's access rules, hooks, validate and function defaultValue, none of which survive being stored, and a reload never went back through service registration: an edit to any of them kept running the version from process start until the dev server was restarted. That is wrong in the direction that matters most for an access rule, since a rule tightened in the config was not the one being enforced. Applied on the same optimistic terms as the field-type registry, and restored by the same undo when a reload is abandoned.

    An access rule declared inside an unnamed container is captured. The registry keyed only named entries, so every rule, hook and validator inside an unnamed presentational group was dropped. defineCollection refuses a field with no name, but a plugin contributing raw config is checked on its field TYPES and not their names, so the shape reaches the live config.

    A Single's first read no longer invents an incomplete group. Filling a group for the sake of one defaulted child, while a required sibling has no default and no value, stored a document the next create or update would refuse; that insert runs no validation pass of its own. The group is left absent instead.

    The documentation no longer claims a field group's children take their defaults before the entry's hooks. They are written in their own pass afterwards, so the parent's hooks see such a child as absent.

    A defaultValue written as a function now works on a reusable field group's children. A field group's fields are read from its stored definition on every write, and a function does not survive being stored, so only constants applied there: a function default on a field-group child was silently dropped on every write. Field groups now get the same live-config capture collections and Singles have, so the function form resolves from the config at boot, and it is re-read when the dev server reloads the config. It resolves against the instance being built, so a child may compute from a sibling defaulted before it.

    Only the default is wired. The same capture holds a field's access rules, hooks and validate, and nothing reads those for a field group, so registering them changes nothing about whether they are enforced.

    The promote gate judges the draft the write actually commits.

    The gate that re-judges a Single's pending change before it is published ran before the write transaction, where it could only judge a copy of the world as it was. It now runs inside that transaction, on the draft the transaction has locked, which closes what a pre-flight check could not: a draft saved by another writer between the check and the commit is now the draft that is judged; a beforeChange hook that turns a status-less edit into a publish no longer slips past a check that ran before the hooks; and publishAllLocales, which applies every language's snapshot to one row, now judges the combined result rather than each language against its own shared values. Resolving the caller's grants is the one thing that cannot happen inside a transaction, since it queries the pooled connection that transaction holds, so it is resolved beforehand and handed in.

    A field rule on a child of a group or a repeater row is enforced. The check copied the snapshot shallowly, so the rules deleted a denied nested value from the copy and the original alike and the comparison then saw an unchanged container. A denied child is now found at its own depth and named at its own path.

    A publish is no longer refused over a field nobody touched. The comparison read the live document through a read that expands an upload or a relationship into the document behind it, while the snapshot holds the identifier, so an untouched field of either kind looked like an edit. Both sides now go through one conversion.

    A draft older than a newly required field is refused rather than published. The check judged the promoted document as a patch, which skips absent properties by design, so a snapshot written before a field became required reported nothing and published a document that violates the contract.

    An API key is judged on the grants stamped on the key, not on the database roles of whoever owns it.

  • Every sortable table in the admin can be reordered from the keyboard, and a screen reader is told what is being moved.
    More

    Two tables -- the collection fields list and the user fields page -- accepted only a pointer, so a keyboard user could not reorder them at all. They now use the same sensors as every other drag surface: Space or Enter picks a row up, the arrow keys move it, Space drops it, Escape puts it back.

    What a drag says has changed on every surface. The defaults read the draggable's id aloud -- "Picked up draggable item 3f9a…" -- and every id here is a field name or a uuid. A drag now names the thing and where it sits: "Picked up Title, position 1 of 4", "Title is over Body, position 3 of 4", "Title moved to position 3 of 4". On the dashboard the same sentences name the card and its column, matching what the Move buttons already say, so a card moved by keyboard and one moved by button sound the same. Each drag handle is also named after its row, so tabbing through them no longer reads as a list of identical buttons.

  • A field hook now runs for the fields a write touched, and reads the whole row either way.
    More

    A localized update assembles the complete translation onto the row it returns, because the version snapshot and the outgoing events describe a translation rather than the one field of it that moved. Field hooks were selected by what was present in that row, so an afterChange handler on a sibling nobody edited started firing for an unchanged value — sending mail, re-indexing and calling out for a field the write never named.

    Which handlers run and what each handler can see are now separate questions. The set of touched fields decides the first; the row stays whole, so a hook that derives a search document from an unchanged neighbour can still read it.

All 64 changes
  • The engine's three whole-document measurements — countNodes, treeDepth and documentBytes — now refuse a forest they cannot afford to walk instead of grinding through it or raising a native error.
    More

    They walk ENTRIES, not objects, and deliberately so: one node object placed in two slots is two elements of the document, and counting it once would report half a real size and pass a cap the document exceeds. That makes the walk exponential in depth for a forest whose branches share a node object — 21 shared objects reach 2,097,151 entries, and every further object doubles it.

    documentBytes was the sharp edge. JSON.stringify expands each shared node into a copy per path that reaches it, so it allocated 132 MB for 21 objects and raised RangeError: Invalid string length at 23 — from a document a few kilobytes in memory, in the one function that decides whether a document may be stored.

    All three now throw ForestTooLargeError, against the ceiling that fits what each one actually spends:

    • countNodes and treeDepth refuse past MAX_VALUE_PARTS (4,194,304) — the ceiling the op layer's preflight already refused values at, now shared rather than duplicated.
    • documentBytes refuses past MAX_SERIALIZED_VALUES, which is derived from the byte cap it protects: every serialized value contributes at least one byte, so a document that has emitted more values than the cap has bytes cannot come in under it.

    Sharing the first number is the point: a second, lower ceiling for the structural readers would let a dry run accept a document the apply then refuses, and would sit below maxNodes on a site that legitimately raised it. The serializer keeps its own because it bounds a different resource — a structural walk reads, while serialization BUILDS as it goes, so one numeral buys different amounts of work in each. Both are exported from the package root, and @nextlyhq/blocks-engine/format exports the error with MAX_SERIALIZED_VALUES, the only one bounding anything that entry exposes.

    The messages name the routes to the ceiling without claiming which one a caller hit, because no reader compares object identity and so none can tell. Inside applyOp the refusal arrives as an OpError like every other, so the ...Refusal helpers still return a reason rather than throwing at their caller. Composition and the builder's deletion metadata degrade rather than propagate it: an unmeasurable subtree refunds nothing and reports no descendant count, so a page still renders and a block can still be deleted.

    Nothing a site can store is affected. JSON.parse produces fresh objects and cannot express sharing, so a stored document is never such a forest; the bound is reachable only by a forest built in memory by code.

  • A spacing handle stopped following the pointer when a drag took a margin past zero.
    More

    A margin dragged below zero is drawn on the other side of the block's edge, and its two sides swap roles when that happens. The handle kept the side it was given when the drag began, so once the value crossed zero it sat on the edge that no longer moves and the block stopped responding — the drag had to be released and started again to carry on.

    The handle now takes the side the band is drawn with as it is drawn, so a stroke that runs a margin from positive to negative keeps moving the block the whole way.

  • The Fonts panel no longer warns that a font stack will be dropped when a var() names its custom property with a CSS hex escape.
    More

    var(--\62 rand) is var(--brand) to a browser, and is now read as the same working substitution.

  • A hook could not tell whether the write it was running on came from a browser or from a seed script, so a rule that only makes sense for a visitor, a rate limit or a honeypot, could not be written at the write seam at all.
    More

    It had to live in one route, and a collection that grants public create has more than one door into it.

    Collection operations now accept the HTTP request that produced them, and the core resolves it into the facts hooks read as ctx.req: the headers, and a client address judged against this deployment's security.trustProxy and TRUSTED_PROXY_IPS rather than read raw off x-forwarded-for, which is whatever the sender chose to claim. ctx.req.http is absent when no request produced the write, and that absence is what tells a request-scoped rule to stand down instead of judging a server-side import as a visitor.

    Both HTTP doors pass it down, and ctx.services.collections takes it too, so a plugin serving its own route can hand over the request it was given. The form route's audit column now records the resolved address instead of the leftmost forwarded hop.

  • A job handler is told which job it is running.
    More

    Delivery is at-least-once, so one queued job can reach a handler more than once, and the documentation says to write handlers that survive that. It did not say how. The context carried the identity, the clock, a content API and the tick deadline, and nothing that identified the queued row, so a handler had nothing stable to key an external side effect on.

    It now receives jobId, the same on every attempt, which is the key to hand a payment provider's idempotency header or the unique column an upsert targets. It also receives attempt, counting from 1, which is written to the row before the handler starts, so a handler that dies part-way still leaves the count advanced. It is not the deduplication signal: attempt > 1 says an earlier run began, not what it finished.

    The same page told operators to size leaseMs to the work. Nobody could: the built-in route passes only a batch size and a duration, and the public configuration takes job definitions alone. It was also the wrong advice, since the lease is renewed while a handler is alive and so already covers work that merely takes a long time. The page now documents the fixed lease as a stall tolerance and points at the key a handler can actually use.

  • A many-to-many field's junction table is now followed by the table itself, not by the field that names it.
    More

    A junction name reused for a field pointing at another collection is dropped and created again — before, CREATE TABLE IF NOT EXISTS kept the old table and its old link column. Changing a field's junctionTable renames its table with the links in it, and pointing a field at another collection gives it a new table instead of none. Two many-to-many fields cannot store their links in one table, whether the name is the author's or the generated one, and a save that would move a junction off a table another field still uses, or onto one that already exists, is refused by name.

  • A scoped API key is judged on its own grants at every access gate, not only the first one.
    More

    Transaction writes, field-level access rules and the coarse collection gate all resolved the permissions of the key's OWNER when the key's own scope did not reach them, so a key issued to read could act with the authority of whoever created it.

    A permission now reaches a code-defined access rule in the spelling those rules are documented to receive (posts:read), so a rule written as ({ permissions }) => permissions.includes("posts:read") decides the same way for an API key as it does for a session.

    The scope a plugin route handler receives is its own copy. Narrowing it in place now affects only that request, where before it edited the key's real grants for every request for the next five minutes.

  • A plugin route received the caller's account and nothing else.
    More

    For a request authenticated with an API key that account is the key's OWNER, so the access check resolved the owner's roles and a key scoped to read was authorized to write whatever its owner could — reaching, for a key minted by a super-admin, the unconditional super-admin allow.

    AuthenticatedScope already existed for exactly this and is honoured by the collection access services; the plugin route path was the one surface it was never wired into. The key's own grants now travel with the account, from the dispatcher through ServiceOpts and RequestContext to the collection facade.

    A session caller is unaffected: it carries no key scope and resolves the same way it always has, super-admin bypass included.

  • A scoped API key is judged on the scope the request is CURRENTLY under, for the whole of an operation rather than at the one call that names it.
    More

    A route that narrowed its own scope before a transactional write had the narrowing discarded: the transaction methods sit outside the plugin facade's wrapper, so the scope reached the argument and never a gate, and the write was judged on the grant the route had given up. The transaction params carry it now, including the delete path, where the owner predicate was resolved without it — so a key owned by a super-admin took a bypass that belongs to a session.

    A release operation resolves its target before it acts, and that lookup ran with no scope at all, so it read on the key OWNER's grants. The scope is pinned for the operation instead of handed to half of it.

    narrowScope accepts a caller that has none. A signed-in person reaches the same routes an API key does, and requiring each call site to guard that is how a ! reached the documentation — where it was a crash for every session caller.

    The API-key scope built by the Single detail route named actorType and permissions only, so a documented permissions.includes("site:publish") was handed the stored slugs and denied a key that held the grant.

    Hand-writing that scope is refused rather than corrected again, and the refusal runs in two places because one is not enough. A lint selector rejects the literal — in either key order, shorthand, with a nested object between the keys, and with a spread standing in for the second half. The one shape it cannot see is a spread of an existing scope that replaces only permissions, which keeps every original row and so keeps naming a grant the caller has just given up; nothing in the syntax separates that from an ordinary config overlay, so ruleFacingPermissions refuses it where the disagreement decides the answer.

  • Deleting a component that a Layout still uses is now refused, and the message names the Layouts to go and edit.
    More

    A Layout wraps every page assigned to it, so removing a component it uses would leave a gap on all of them at once — unlike an ordinary page, where the renderer draws one visible, recoverable placeholder.

    Draft Layouts count. A component named only by an unpublished Layout is on no page yet, and deleting it would break that Layout the moment someone publishes, by which time the cause is a deletion nobody remembers.

    The database could not have caught this: a Layout's areas are stored as one JSON column, so the reference to the component emits no foreign key.

  • The collections and singles listings show exactly what the reader may open.
    More

    Both lists scoped themselves by the stored {slug}:read grants alone, while every other read -- opening a document, the dashboard's own scope, a version read -- also consults the entity's code-defined access.read. The two disagreed in both directions: a collection or Single authorised entirely in code has no grant row, so it was left out of the list a reader could open it from, and on the dashboard the singles card was offered for it and then drew nothing; a grant the code rule refuses was listed anyway.

    Both listings now take the same read decision as everything else. One consequence for API keys: a key owned by a super admin no longer lists every collection and Single. It is judged on its own stamped scope, which is the rule every other read path already applied to it.

  • An author who opened a document somebody else was already editing had exactly one thing they could do about it: take the document over, displacing a colleague mid-sentence.
    More

    The polite option was to close the tab and hope.

    The lock strip now offers "Request edit access" beside "Take over", and the holder is told — passively, in the same strip — that someone is waiting. Nothing else changes: the request moves no claim, asks the holder for no answer, cannot be refused, and the lease expiring stays the only thing that transfers a document. Every action the holder had, they keep, including the Save they are being given time to reach.

    The ask is a standing one rather than a single message. The editor that is locked out is already polling for the document on every beat, so the request rides that poll and is re-stated for as long as the person is still there and still waiting. Close the tab and it lapses, so a holder is never nudged on behalf of a colleague who has gone.

    Pressing the button is answered. The strip replaces it with "We have let Bob know you are waiting" — text, not a disabled button, because a control that stays on screen having stopped doing anything is what a broken one looks like. The confirmation is shown only when the SERVER has the ask on record, so a request that landed nowhere cannot be reported as delivered.

    The holder's notice is a status region and not a dialog. There is no APG basis for one here — that pattern is for urgent interruptions a person must answer — and it is spoken once when it appears rather than on every heartbeat.

  • A field added to a collection now becomes the same column it would have become when the table was created.
    More

    Three implementations answered "what column is this field?", and the one the product READS through — the canonical descriptor, used by the runtime Drizzle table and by the schema diff — was not the one the Schema Builder wrote with. So the table a user got was not the table the rest of the system believed it had, and the failure surfaced far from its cause: a diff proposing the same change on every run, or a write rejected by a column whose type nobody declared.

    Both paths that CREATE a column now read the descriptor. The path that alters an existing column deliberately does not, and that boundary is the whole safety argument: restating a live column under a mapping it was not built with is a narrowing MODIFY on MySQL that truncates stored values for an edit that touched nothing but a flag. No existing table is altered by this change.

    Two of the fixed disagreements were losing data outright on a column added to a table that already existed:

    • A number field set to float was added as a whole-number column on all three databases, silently discarding every fraction written to it. It is now float8 / double / real, which is what the same field gets when its table is created.

    • A text field declared short was added unbounded on PostgreSQL and MySQL, losing the width the field asked for.

    Both happened because the ADD COLUMN path rendered a column from the field's type and length alone and never saw its options or validation.

    One family of disagreements is deliberately NOT converged here. The descriptor stores a field holding many values (hasMany numbers, uploads, relationships) and a repeater or group as a JSON array where these generators emit a scalar. That changes the column's storage class rather than its spelling, and the type is not the only thing that would have to move with it: indexability is decided from the old rendering, a relationship attaches a scalar foreign key, validation bounds are emitted as a comparison against the column, and a required column's backfill derives a scalar default from the declared type. Taking the descriptor's type alone would emit CREATE INDEX on a JSON column, a foreign key from an array to a scalar id, and json NOT NULL DEFAULT 0. Those stay recorded as known disagreements until the consumers move in one change.

    A MySQL MODIFY issued only to change a column's nullability now restates the column's LIVE type, read from the catalog, rather than a rendered one. MySQL restates the whole definition on every MODIFY, so such an edit has to name a type — and once new columns come from the descriptor, no renderer is right for both: rendering the old mapping narrows a float created as double to decimal(10,2), and rendering the descriptor narrows a select created as unbounded text to varchar(255). Both truncate, for an edit that asked for nothing but a required flag. The database knows which it is, so it is asked. A caller that does not read the live table keeps the previous behaviour.

    Some column types are spelled differently as a result — int4 for integer and bool for boolean on PostgreSQL, tinyint(1) for boolean on MySQL. These are the same types under the names the descriptor and the database's own catalog use, not storage changes.

    One consequence worth stating plainly: on MySQL a select or radio column created from now on is varchar(255) where it used to be unbounded text, because that is what the descriptor says. Nothing existing is affected and nothing can be truncated, since only columns that do not yet exist are rendered this way. Whether the descriptor should instead move to text for these types is a separate open question.

    The conformance matrix that pins these three implementations against each other lost 23 accepted disagreements, all of them on the collection generator's create and add-column paths. Its ratchet is checked in both directions, so an entry describing a disagreement that no longer happens fails the suite — the list could not have been left stale.

  • A new package, @nextlyhq/plugin-mcp, for exposing an install to AI agents over the Model Context Protocol.
    More

    Experimental, read-only when it arrives, and inert in this release: it contributes no route, no field and no permission, so installing it today changes nothing. It is published now so that the protocol surface lands as additions to a package that already exists rather than as one drop, and so its release path is proven before anything depends on it.

    enabled defaults to false and stays that way while the surface is experimental. What the endpoint will expose is an install’s schema and content to any client that can reach it, so a version bump must never be what starts serving it.

  • Saving a published page no longer puts the edit live.
    More

    Pages now keep a working draft, as components and Layouts already do: Save stores the draft while the site keeps serving what was published, and Publish changes puts the draft live. A new page offers Save draft and Publish.

    Existing sites see this on their published pages from the first save after upgrading, with no migration. Pages also gain autosave recovery points while an author works in the page editor.

  • An edit made to a collection that has been saved but not yet deployed no longer narrows the column its own creation migration is about to write.
    More

    Such a collection has a registry record and no table, and the two artefacts replay in order: the create runs first, then the edit. Everything the edit believes about that table is therefore a prediction, and the indexes and foreign keys were already predicted for exactly this reason. The column TYPES were not — they were reported as unknown, which sent MySQL's MODIFY to the legacy renderer.

    The two renderers now disagree by design, so that fallback had a consequence: a float field is created as double, and a follow-up requiredness edit in the same window emitted MODIFY COLUMN ... decimal(10,2). Both artefacts are correct read alone, and applying them in order narrows and rounds a column the deployment had just built.

    The planned types are predicted by the same class that emits the CREATE, beside the planned indexes and keys, so a prediction and the statement it predicts cannot describe different columns.

  • A plugin can contribute a dashboard-widget DATA SOURCE.
    More

    contributes.widgetSources takes a source and the server-side function that answers it, together: the source declares its queryable fields and supported ops the way every other source does, and the resolver is handed the query, the caller, and its own plugin context to read through.

    What the validation bounds is the SHAPE of the question -- field names, operators and operand shapes, all against the source's own declaration. It does not constrain operand VALUES, so a resolver must treat every value in a query as caller-controlled and must not use one as an outbound URL, a path, or a table or column name without checking it against a closed set of its own.

    Both halves travel in one value, so a source that nothing can answer is not a state a plugin can reach. A contributed id must sit in the plugin: namespace; collection:, single: and system: are refused at boot, as is a duplicate id or a missing resolver, each naming the plugin that declared it. A disabled plugin contributes nothing, and a boot starts from an empty store, so a source never outlives the plugin that published it.

    The dashboard query endpoint now bounds each slot. The batch answers with Promise.all, so it was only ever as fast as its slowest query: one that never settled held every other card behind it and the reader saw nothing at all. A slot that exceeds its budget now fails on its own and its siblings still answer.

    WidgetSourceResolver, PluginWidgetSource and ReadCaller are published from @nextlyhq/plugin-sdk, so a resolver can be written as a named function rather than only inline. They are @experimental on the same ladder as the rest of the widget contract.

    A contributed resolver is handed its plugin's own context, so it can read data to answer with. A plugin's services are reachable through nothing else, so the first shape of this contract could return constants and little more.

    The resolver type a plugin author writes against is published as PluginSourceResolver. The existing WidgetSourceResolver is core's own two-argument shape and rejects the context parameter a contributed resolver needs, so typing one with it made the contract reachable only inline.

    A plugin's managed read keeps the claims the caller's token proved. The identity was rebuilt from id, name, email and roles alone, so a collection's code-defined access rule reading a tenant, plan or entitlement was judged on a different caller than the request authenticated -- denying a positive check, and granting an absence-tolerant one.

  • A plugin can now serve a route at a top-level path instead of under /plugins/<plugin-name>, by declaring mount: "root".
    More

    That is what lets a plugin take over an endpoint the core stops shipping without every caller having to change the URL they already use.

    It cannot take a path the core serves. Root routes are matched only after the built-in router has declined, so the core's answer always comes first: a plugin declaring /collections gets the collections API like anyone else, not control of it. The ordering is the guard rather than a list of reserved prefixes, which would have to be updated every time the core gained a route and would fail silently when it was not.

    Two plugins still cannot claim one address: rooting a route keeps the collision check that the namespace used to make unnecessary. That check asks within a mount, because the two are matched in separate passes and a root route that merely resembles a namespaced one never competes with it for a request.

    A route is also refused at boot when it is rooted somewhere the request never arrives, rather than registering and silently answering nothing. That covers /auth, /plugins, /admin-meta and the development endpoints, all of which are answered before the point a root route is consulted. A mount outside "plugin" | "root" is refused for the same reason: only an untyped caller can produce one, and it would register under one name and be looked up under another.

    PluginRouteMount is exported from nextly and @nextlyhq/plugin-sdk beside PluginRoute, so a plugin author writing a reusable route builder can name it.

    A request that could reach a plugin route always waits for initialisation to finish, not merely for the routes to appear. The route registry fills partway through startup, so a second request arriving in that window could previously run a handler before permissions were seeded and migrations had settled.

    Nothing changes for a route that does not ask. The default is unchanged, so every existing plugin route stays where it is.

  • A plugin can update many entries in one call.
    More

    ctx.services.collections ended at createMany: plugin code could write many rows in one call and then had no way to change them in one, elevated or not, so the only batch update available to it was a loop of updateEntry calls, each with its own transaction, its own access pass and its own cache flush. updateMany(slug, entries, opts?) takes one { id, data } per row, so a single call can apply a different patch to each row, and returns the same BatchOperationResult createMany returns: partial success, with errors[].index indexing the array the caller passed. There is deliberately no by-filter form; listEntries and this method compose to the same thing with the rows named, and a filter that matches more than its author meant is the failure a batch write cannot take back. A locale is refused by name, as on createMany, because the bulk pipeline writes in one pass and cannot store a translation. @experimental on the plugin surface until a first-party plugin exercises it.

  • A plugin can now name the locale it reads or writes in.
    More

    ServiceOpts — the options every ctx.services.collections call takes — gains locale and fallbackLocale, spelled as the request context and the REST API already spell them, and both are carried through to the collection services. Until now the request context could hold the pair and every read accepted it, but nothing on the plugin path could say it and the facade's forwarding seam dropped it even when a context did: every plugin read and write on a localized site was in the default language, silently. A code that is not a configured locale resolves to the default on a read, as it does on the wire, and is refused on a write, so a typo cannot overwrite the default language's content. createMany refuses any locale by name rather than filing the rows under the default language silently; its bulk pipeline cannot store a translation yet. deleteEntry refuses one likewise: a delete removes every translation with the document, and a locale on it would read as removing one. The selectors * and all are refused outright, from one classifier now exported as isLocaleSelector from @nextlyhq/plugin-sdk: a value forwarded from a query string must never be able to publish every translation of a document. The same spellings are reserved at localization configuration, together with none, the wire's spelling of no fallback — a site can no longer name a language all or none, neither of which could ever be read, written or chosen as one. An empty locale reads as none named.

    The form builder uses it for the one place a visitor could see the gap. A form that redirects to a picked page read that page with no locale, so a page whose slug is thanks in English and merci in French sent every visitor to /thanks. submitForm takes locale, and the built-in POST /api/forms/:slug/submit reads it from ?locale= — the same place core's own routes take a locale — so a French submission is answered with the French URL, and one that names no language is answered as before. The target is read as the visitor rather than as the system, so a translation still in draft is never the URL a visitor is sent to; the published default answers until it is published. The read wildcards (all, *) are not a language and read as none.

  • A plugin route can now ask what its caller may do, not only who they are.
    More

    ctx.caller carries authMethod, the authenticating apiKeyId, the token's verified claims, and can(action, resource) — answered through the same machinery that decides a route's own requiredPermission: a scoped API key on its own stamped grants and the code-defined rule evaluated against them, and a session through the RBAC service with its super-admin bypass. A super admin does not bypass an API key's scope.

    This is additive and changes no existing behaviour. It exists because ctx.authenticatedScope answers only for API keys — a session caller's grants are resolved on demand and it holds no stamped scope — so a route could not ask "may this signed-in author create in collection X", which is the question an admin panel needs in order to gate a create action before showing it.

    Deliberately not a permission array: a session's is empty by design, so a route reading one would refuse every signed-in user while appearing to check something. The write remains the enforcement point; this is a UX and routing aid.

  • A plugin-contributed route reached the collections through the managed facade without carrying the request it was serving, so a hook underneath one read a browser's write as background work.
    More

    Every route written before that field existed names no request, which is all of them: the page-builder's save route and the form-builder's export routes among them. A request-scoped rule, a rate limit or an audit, stood down for exactly the traffic it exists to judge.

    The route dispatcher pins the request once, beside the caller scope it already pins, so a contributed route inherits it without naming it.

  • A database error from the adapters now says which operation failed on which table on every dialect.
    More

    The context was attached only when the message did not already contain the operation's name, and on PostgreSQL and MySQL the driver's message quotes the failed statement — which Drizzle spells in lower case — so an update, insert or delete there, or any table or column whose name contains the word, arrived without it. The error still carries the table as before; only the message changes, and only where the context was missing.

  • A reader can send a first-run card away from the card itself, and dismissing one no longer stops later widgets from reaching them.
    More

    A widget declaring dismissible draws a control on the card, outside edit mode: a card that addresses somebody who has just arrived should not require discovering the dashboard editor first. Framed cards carry it in their header; unframed cards float it in the free corner. It is offered only from the width at which editing -- the one route to bringing a card back -- is available, and dismissible must be a boolean on both declaration channels.

    Dismissing hides the placement rather than deleting it. The write has its own channel, so its failures never reach the editor's chrome, it locks every other layout write while it is in flight, it confirms against the refreshed dashboard before announcing, and focus moves into the widgets region when the card holding it goes.

    A layout row now records whether its reader ARRANGED it. A row written only by dismissals still follows the live registry -- a widget declared later is placed, and positions track the declared order -- with the reader's dismissals applied. The editor's save takes charge of the arrangement, and a row once arranged stays arranged whatever a later write says. Every row written before this reads as arranged, so existing dashboards are unchanged; the flag lives in the stored JSON, so nothing migrates on any dialect.

    toggleHidden and remove now announce through the grid's live region, where both used to change the dashboard in silence.

  • A function default cannot read a denied sibling from inside a group or a repeater row either.
    More

    The create path judges the field rules on a copy of the request and hands that copy to the function defaults, so a default cannot read a value its writer was not allowed to send. When the rules removed a whole container, the copy had no counterpart for it, and the walk read that as no copy having been given at all: it fell back to the caller's own row, which is exactly the unfiltered data the copy exists to replace. A nested default could then carry a denied sibling into a child the caller may write, while the pass that decides what is stored removed only the field it came from. A container missing from the copy is now read as a container the rules emptied, which is the only way one goes missing.

    A Single's first read judges a required nested child by the rule the write validator will apply. required: true on the field and validation: { required: true } beside its other rules are both supported spellings, and this read tested only the first, so a group invented for one defaulted child was stored with a required sibling empty and the next write refused the document. The validator's own predicate is shared now rather than restated.

  • A class rename the host refuses with an empty or whitespace-only reason is now reported in the same words a rename that threw already used, rather than as an alert with nothing written in it.
    More

    The refusal's shape is the documented one and the host did refuse, so silence would report a failed rename as a success; what was missing was only the words, and those are supplied.

  • A class rename refused by a host is shown to the author only when the refusal carries the reason the panel is about to print.
    More

    The guard that recognised an outcome read one field of it — whether ok was a boolean — and then narrowed to a type promising reason: string, so any unrelated failure-shaped result a host handed back was accepted as a refusal. { ok: false, error }, which is what an ordinary mutation helper answers with, reported undefined.

    The notice renders on the reported reason not being null, and undefined is not null, so the author got an error box with nothing written in it and a screen reader announced an alert carrying no text — a rename declared failed with no way to learn why. A result this cannot vouch for is silence again, as it always was for a host that answers with nothing.

  • DropRegion.at is now DropRegion.target.
    More

    The module spelled two ideas with one word: on a region, at was the placement a block would be nested into, while on a DropTarget, at was a position and target was that placement. A host reading both had to remember which at it held. The region now names the placement as the target does, and at means a position everywhere in the module. A value still spelling at on a region is a type error rather than a silent fall-through; the package is @experimental throughout, so no alias is kept.

  • An edit to what a relationship does when the row it points at is deleted now reaches the database.
    More

    A foreign key carried the actions the statement that CREATED it wrote, and nothing else ever changed them. Moving \posts.author\ from cascade to restrict saved successfully and recorded restrict, while the database went on cascading — so deleting an author still destroyed their posts. The same held for a many-to-many, whose junction kept the actions it was built with.

    Both are emitted now, through one implementation. PostgreSQL and MySQL drop the constraint and declare it again under its own name, as two statements: MySQL rejects a drop and an add of one name in a single \ALTER TABLE\, and on PostgreSQL a single statement would depend on the order the server applies its subcommands in. A junction has both of its foreign keys rebuilt, and the table itself is left alone — rebuilding it would destroy every link it holds for a change that never needed to touch one.

    SQLite refuses the edit by name rather than performing it. It cannot alter a constraint at all, and the table rebuild that would be required has caused real data loss in three independent tools that automated it; refusing is what this dialect already does for a foreign-key drop and for a unique constraint it cannot enforce.

    Three things the statements meet on the way to the database are handled with them, because emitting the right SQL is only half of arriving:

    • They are emitted one statement per chunk. The runner splits a migration on its breakpoint markers and never on semicolons, and the MySQL driver is configured to refuse a query carrying more than one statement — so a semicolon-joined pair was rejected whole, and this edit did nothing at all on MySQL.

    • Turning a link optional now relaxes its column as well as its key, in that order, and turning one required replaces the key before tightening the column. A relationship's requiredness never moved its column before: the descriptor calls the column nullable whichever way required is set, so an optional link kept a NOT NULL column while its key moved to SET NULL — which MySQL rejects outright, and PostgreSQL accepts and then fails on the first delete, in production.

    • The key that is dropped is the one the table actually carries, read from it rather than derived from a naming convention. A column whose key was installed under another name, or that carries none because it was edited from a scalar into a relationship, no longer aborts the migration.

    The edit is also paired the way the rest of the save pairs, rather than by name alone: a relationship renamed in the same save carries its action edit (it previously emitted the rename and left the old action enforced), and a field whose storage moved leaves its key to the path that creates it rather than declaring the same constraint twice. Many-to-many junctions are paired once for both their table move and their action edit, so a save that did both can no longer fall between the two.

    Two referential actions are now refused rather than emitted for a server to reject halfway. onUpdate: "set null" on a required relationship is the pair onDelete has always refused, reached through the other half. And set null on a many-to-many cannot hold at all: both link columns are NOT NULL, because a link naming nothing on one side is not a link.

    One behaviour changed on SQLite. A junction action edit was refused by name when the junction kept its table and silently ignored when the same save also renamed it — so whether the edit was refused or lost depended on whether you happened to rename. It is refused in both cases now. SQLite still cannot change a junction's referential actions; renaming one on its own is unaffected.

    Two further things the column's METADATA cannot answer are now asked of the column itself:

    • Installing SET NULL states that the column accepts nulls rather than inferring it from requiredness. A database migrated before a requiredness toggle relaxed anything still carries whatever CREATE TABLE gave the column, so a relationship both definitions call optional can be sitting on a NOT NULL column right now — and the statement is idempotent, so this also repairs the ones the old behaviour left behind.

    • Making a field required is refused, before any statement is written, when entries still leave that column empty. The server rejects the tightening, and by then the statements ahead of it have run — auto-committed on MySQL, including the foreign-key replacement a relationship's tightening is ordered behind, which would leave the table carrying no key at all while the save was recorded as made. The check reads the live rows; a caller that does not supply them keeps the behaviour it had.

    A definition already stored is read rather than judged. The previous creation path accepted a required relationship declaring onUpdate: "set null", and refusing to READ that combination would have frozen the collection holding it: the repair is itself an edit, and every save visits every relationship the collection keeps, so one legacy field would have blocked unrelated changes to its neighbours. What a save ASKS FOR is still refused — including a save that flips requiredness onto a declaration that was legal before.

    What a field was is now answered in ONE place for every pass in a save. The action pass carried a renamed field's edit while the column pass, keyed on the new name, skipped the same field — so a link renamed and turned optional in one save had its key moved to SET NULL and its column left NOT NULL.

    The SQLite refusal in the schema templates is a NextlyError rather than a bare Error subclass, so it reaches a caller as the typed envelope every other refusal in the package uses. The class and its message are unchanged; callers and tests identify it by type.

    Two things about the check above, both found before it shipped:

    • It asks only about columns that are ON this table. A localized collection keeps its translatable columns in a companion, so probing the main table for one asked for a column it does not have — and that error arrives before any migration is generated, which would have failed every save on a localized collection with an optional translatable field.

    • Relaxing a column for a SET NULL key keeps the default it carries. MySQL restates the entire column definition on MODIFY, so a narrower rendering silently removed the relationship's configured default for future inserts. Both callers now render that statement through one function.

    A column the live table does not have YET is its own answer, not a clean one. Its ADD is queued, so it holds no nulls today — and reading that as "no nulls" let a save make the field required, after which the deployment creates the column holding NULL in every existing row and fails on the next statement. Absence is reported separately now and refused only where the table already has entries, with its own message: deploy the change that adds the field first, fill the entries in, then make it required.

    Which columns exist is read from the database rather than predicted from the field definitions. Predicting it was wrong twice — a localized collection keeps its translatable columns in a companion table, and a deployment holding an unapplied migration has a field whose column is not there yet — and each repair only covered the reason someone had just met. The reader now narrows to the columns the catalog reports before it probes anything, so it cannot be asked about a column that is not there whatever the caller believed. The caller's list is a query-reduction narrowing and says so.

    The requiredness precondition uses the save's own rename pair, like every other pass in a save. A field renamed and made required in one go read as newly added, skipped the refusal, and had the tightening emitted anyway; the nulls are recorded against the column the live table still has, so that is the name the check reads.

    The live column read is resolved to the catalog's own spelling of the table. MySQL under lower_case_table_names=1 answers a query for a mixed-case table by reporting the name it folded, so the map came back keyed as dc_posts for a table asked about as Dc_Posts and an exact lookup missed the very table it had just described. That miss reported no nulls and no absent columns, which is indistinguishable from a clean table, so the refusal above withdrew itself on exactly the server whose DDL auto-commits. The folding rule is given rather than queried, for the reason the apply pipeline already records: the only name being matched is the one this read just asked the server to describe, so a case-insensitive match cannot select a different object. PostgreSQL is left case-sensitive, where the two spellings are two different tables.

    Singles ask the same question of their own table. The precondition is keyed entirely on facts the caller supplies, so a caller that reads none does not get a weaker check — it gets no check: the refusal returns at its first guard. The singles path read whether the table had rows and which columns carried keys and indexes, and never read the null state, so a single that tightened a field over an entry leaving it empty, or over a column an undeployed migration has not added yet, still had the nullability statement written for PostgreSQL and MySQL to reject. It reads both now, through the same reader the collection path uses, narrowed to the columns this pass will actually diff — a localized single keeps its translatable columns in a companion table and they are not this pass's to ask about.

  • A many-to-many field's junction table now follows the field through its life in the migrations the builder writes.
    More

    Adding a many-to-many relationship field has always created its junction table; nothing removed it. A removed field left the table standing with every link in it — unread, and inherited by any field added later under the same name, because the creation runs CREATE TABLE IF NOT EXISTS. A renamed field was worse: the rename detector only pairs fields that have a column, so a many-to-many rename fell to the add/drop loops, which created a fresh, empty junction for the new name and left the old one, links and all, orphaned. Dropping a collection dropped its table and its _locales companion and left its junctions behind.

    Now:

    • Removing a many-to-many field emits DROP TABLE IF EXISTS <junction>, the table resolved by the same rule the creation uses (the author's junctionTable name when set, the generated name otherwise). Moving the field to a storage class that has a column does the same, then adds the column.
    • Renaming a many-to-many field — one removed and one added that point at the same target under the same relation kind — emits ALTER TABLE <old> RENAME TO <new>, spelled the same on PostgreSQL, MySQL and SQLite, so the links travel with the name, and then renames every index and constraint whose generated name embedded the old table name (PostgreSQL renames them; MySQL renames the indexes and re-declares the foreign keys under their new names; SQLite rebuilds the indexes and needs nothing for its per-table constraints). Without that, a later field reusing the old field name would find its index and constraint names already taken. A junction the author named keeps its name and emits nothing. More than one such pairing in a single save is refused by name (MANY_TO_MANY_RENAME_AMBIGUOUS), as two field-group renames already are: a wrong pairing would hand one field the other's links. Removals and additions that pair with nothing are plain drops and creates.
    • The junction lifecycle is decided on the full field lists, so a localized: true many-to-many — which the column diff of a localized collection never sees — is created, renamed and dropped like any other; and a save that renames a many-to-many and a field group together carries both.
    • Dropping a collection drops the junctions its own fields own before the companion and the main table. A junction that another collection's field points at this table through is that field's, and stays with it; what should happen to such a field when its target collection is dropped is a separate question, filed for a decision.

    generateDropTableMigration takes the collection's fields (defaulting to none) so it can name those junctions; the collections delete path passes them.

  • Saving part of a page as a pattern no longer gives a block an id it never had.
    More

    When a pattern is inserted beside an element that already uses one of its ids, the insert renames the copy (pricing becomes pricing-4985ccb3) and records the rename, so a later save can store the pattern under its own name again. That record said what an id became but not which block it belonged to, so a block added afterwards and given the renamed id was also saved as pricing.

    An insert now also records which blocks it renamed, as renamedNodes beside renamed on the inserted root's origin. A save restores an id only on a block listed there that still exists once on the page and still carries the renamed id. A block that was deleted, whose id was changed, or that was added later keeps the id it has. Hiding a block behind a visibility condition does not change this.

    Existing pages behave as they do today. A pattern inserted before this release has no such list, and saving from it restores by id exactly as before, including the case above. Inserting the pattern again writes the list, and saving over a pattern keeps whichever form its record already has. patternRenameRecord reads a stored record's renames and its list together, in one pass.

  • A rename of a site class survives the panel it was started from.
    More

    The class manager lives in a rail that unmounts it on every switch, and that switch is exactly what makes two renames of one class overlap: the author renames, moves away, comes back, renames again, and the first write is still on the network. Two things were decided from state that the unmount destroyed or that the wrong attempt cleared.

    The name a class is HEADING FOR is now released only by the rename that is still the live one. An earlier attempt finishing under a later one used to release it for both — and the panel then judges the next edit against the name on screen rather than the one being written, so typing the original back reads as "nothing changed" and is silently dropped while the later rename goes on to persist a different name.

    And whether an answer still describes the rename being attempted is now decided from an identity the HOST owns. Kept inside the field, it was destroyed by the unmount and started again from zero, so the first answer to arrive after a switch passed as the current one: a refusal for a rename the author had already replaced was raised from the shell, naming an edit that no longer existed.

    Both decisions now read the same identity, because they are the same question asked twice.

  • A plugin route can compute the permission it requires.
    More

    requiredPermission took a fixed slug, and a permission slug spells a collection — which the host can rename. A route gating on one of its own plugin's collections therefore had to choose between demanding a grant nobody was seeded on the installs that renamed it, or declaring no permission at all. The page builder's save-pattern route chose the second, so a write was reachable by any authenticated caller.

    It now also accepts a function of the plugin's own resolved names:

    requiredPermission: ({ collection }) => collection("patterns", "create"),

    The scope composes the slug through the same helper core seeds with, so a route never spells one itself, and the demanded grant follows a rename. A resolver that throws refuses the request rather than falling through to the ungated path.

  • A dashboard chart's table draws its row separators at full strength.
    More

    At half alpha they measured 1.11:1 against the page surface, where WCAG asks 3:1. A separator in a data table is structural rather than decorative — it is what tells a reader which number belongs to which row — so it carries the contrast a reader needs, and it now matches the shared table primitive every other table already uses.

    The plugin-route documentation's scope-narrowing example is a complete route rather than a fragment. It referenced ctx, id and data with nothing declaring them, so a reader copying it got three errors and could not see where those values come from. It now shows the handler they arrive in.

  • Saving a component could do far more work than its bound said.
    More

    Before a save, each of the component's variants is composed to check that the component does not end up referencing itself, and that work was charged only for the component's own nodes. A small component placing a large one composed the large one again under every variant, and a large component naming itself was not checked at all on a save made without the Direct API.

    Each composition is now charged, by the composer itself, for every entry it examines, including expansions it abandons and nodes an override hides, against one allowance per save. A save that spends it is refused, and the message says what the author can change. resolveComponentInstances takes that allowance as the work option, and reports loopsClosed, every component a reference loop closed on, so a loop found just before the allowance ran out is still named as a loop rather than as spent work.

  • A single:<slug> widget source is executable.
    More

    WIDGET_SOURCE_KINDS named the kind and nothing published or ran it: a widget query over a single was refused as "not executable yet". Every single the install has is now published as a source beside the collections, from the singles registry at request time, and a list query over it reads the one document through the same access-controlled read as the API -- the single's own rules, code-defined ones included, decide the answer -- and returns it as a list of one row projected to the selection. A single answers a fixed question, so list is the one op it supports: a where, a sort, a count or a bucketing over it is refused by name rather than answered.

    The dashboard offers one status card per single a reader may read -- whether it is published, and when it last changed, linking to the single -- the way it offers cards per collection: never placed, only offered. The singles:present and collections:present conditions now answer from the registries' own readable listing -- what the management cards they gate list -- so a collection or single whose migration is still pending keeps its card on the dashboard instead of disappearing exactly when it needs attention. A single whose DDL a reload refused is withheld from the widget sources the way a collection's is, from one shared store of deferred entities.

    A Single's not-found answer now says which Single it is about: the error's data carries { single: <slug> }, the slug the caller named. A draft-only Single and a nonexistent one still answer identically.

    A single's widget query projects OWN properties, so a field the read removed for a caller -- one named toString or constructor, which a single may legally declare -- stays removed instead of answering with the value Object.prototype carries.

    A collection or single whose metadata sync could not store its new field list is withheld from the widget sources for the rest of the process, beside the ones whose DDL a reload refused: in both cases the registry's description and the table are known to disagree, and a card drawn from one queries a shape the database does not have.

    A single the boot cannot register -- because a collection already holds its slug, say -- now fails the boot naming it, as a collection in the same state already did, instead of leaving the app running without a single its config declares.

    A collection, single or field group can no longer take a slug another kind already holds. Slugs are one namespace across kinds -- as defineConfig already enforced for an app's own config -- and before, such a boot succeeded while the registry silently refused one of the two at sync, so an app's single could vanish behind a plugin's collection of the same slug. The boot now fails with NEXTLY_SCHEMA_SLUG_COLLISION, naming both owners in the log. The same rule is applied to the entities the Schema Builder owns, once the registry is readable -- at the runtime boot and on the CLI -- so a Builder single and a plugin collection under one slug are refused up front rather than at registration, where one of the two was rejected by a message naming neither the other kind nor its owner.

    Under next dev, a single whose fields you edit keeps its source and its status card: the reload re-marks an edited single's migration as applied from the sync's own report, as it already did for an edited collection, instead of leaving the row pending for the rest of the session.

    A widget query asking for draft or published from a source that has no publish lifecycle is now refused, instead of being accepted and answered as if no state had been named. Nothing downstream could apply such a selector, so the two mutually exclusive questions came back with one answer and the card said nothing about having been ignored. status: "all" is unaffected: it claims no lifecycle, and it is what the generated count, recent and timeline cards send.

    A single whose metadata a next dev reload could not store now keeps its widget sources withheld even when the rest of its kind synced cleanly. The singles sync reports a per-single refusal without failing the scope, so a reload that refused one single had been clearing the whole deferral set and republishing that single's stale field list.

    A next dev reload whose metadata sync fails now withholds that kind's widget sources rather than publishing them over tables the apply has already moved, and a single the sync refused no longer has its migration recorded as applied -- a label that persists, so the old field list came back against the new table on the next restart. A boot also starts with nothing withheld: the refusals one boot recorded are its own, and a slug held over from a previous boot kept its source and cards hidden for the life of the process.

    A collection or single whose registry row was written and whose permission seeding then failed keeps its widget source and has its migration marked applied. Both registries report such an entity in errors as well as in created, and reading the error alone withheld a source whose stored metadata was in fact current -- permanently, since every later pass read the same report the same way.

  • Publishing a Single now re-judges the pending change it is about to promote, against the schema and the permissions as they stand at that moment.
    More

    A Single's publish folds its held draft into the live row. Both gates ran when the caller's payload arrived, and for a publish that payload is just the new status, so the draft's own content reached the live row having been judged only when it was SAVED. Two things could have changed since. The publisher may not be the author, and a field rule can deny them a value the author was allowed to write. And the schema can have tightened under a value that was legal when it was held.

    BEHAVIOUR CHANGE, and it is visible. A draft written under an older, looser schema is now REFUSED at the moment someone hits Publish, having raised no complaint when it was saved. The refusal names each field and carries the rule's own message, so the author can see what to fix. Publishing content that violates the current contract is the worse outcome.

    A pending change that edits a field the PUBLISHER may not write is refused too, rather than being quietly dropped from the write. A successful publish consumes the pending change, so dropping the value would have published everything else, deleted the draft, and destroyed the author's edit with it. Refusing keeps the draft intact for someone who can write that field. Only a field whose promoted value actually differs from the live row counts: a draft snapshot is a full copy of the document, so a denied field appears in every one of them.

    Both gates run over the snapshot being promoted and never over the live document, so a schema change cannot block someone from fixing and republishing unrelated content. They run over that snapshot in its logical shape, so a Single that merely HAS a group, repeater or JSON field is not refused for holding one.

    Both publish paths are covered by one gate: the ordinary publish and publishAllLocales, which promotes every language's pending change in its own loop and had no such check at all.

  • A Single read now applies field-level access.read rules before its field-level afterRead hooks, and again after them, the way a collection read always has.
    More

    Before, a denied field's own hook ran and saw the value, and a hook on an allowed sibling could read the denied value; the response was redacted, but app code had already seen it. Access decides on what is stored; hooks shape what is returned.

  • A spacing drag handle sat on the wrong edge for most margins, and dragged backwards there.
    More

    Which edge of a band moves when its value grows is a property of the layout rather than of the box. Measured in Chromium, growing margin-top drives the block's border edge down while its outer edge stays pinned by whatever precedes it; margin-left does the same against the container, and so does margin-right on a block whose width is auto — while margin-bottom, and margin-right on a fixed width, do the opposite. The editor now asks the element on every side, for margins as it already did for paddings, so the handle sits on the edge that responds and the drag follows the pointer.

    It asks each time it draws, so the handle follows whatever the block is doing whenever anything prompts a fresh look: an edit that settles a height, a breakpoint that swaps the width model, a container query answering to a sibling, a state being previewed. None of those has to be anticipated for the handle to be right. A margin that changes only under a real :hover is still described at rest, which is what the editor has always done, because nothing reports that.

    The measurement also leaves the page alone properly now. It works by pushing a value onto the block and putting it back, and on a block with no inline style of its own the attribute was left behind empty — invisible on screen, and visible to everything in the editor that watches the page for edits. Nothing is left behind.

    Two smaller corrections come with it. A handle on a negative margin now grows the value in the direction the edge actually travels, instead of committing a larger negative number and running the block away from the pointer. And a transformed block is measured against the scale its margins are laid out in rather than the one it renders at, which had inverted the handle on any block carrying a transform of its own.

  • A publish that also edits a group, repeater or JSON field no longer fails validation.
    More

    When a pending change existed, publishing it together with an edit to any group, repeater or JSON field was refused as "must be an object", with no field rules involved at all. The ordinary write encodes those fields to their column strings before the publish is judged, and the check on the document being published then read a group as text. Both checks now read the document in its logical shape, through the same conversion that reads the live row, and the write encodes it once.

    A field declared inside a group or a repeater is judged as content, however it is named.

    The publish gate skips the store's own bookkeeping columns, such as id and updatedAt, and defers to the field names the schema declares so that a real field with one of those names is still judged. Those names were collected in a way that stopped at the first named container, so a declared field nested inside a group or repeater was still skipped, and a Single's publish was not given the names at all. The names are collected at every depth now, for collections and Singles alike.

  • The dashboard's onboarding checklist now offers "create your first collection" to every reader who could finish it, and to no reader who could not.
    More

    An administrator who is not a super admin was never shown the step. Creating a collection seeds its permissions to the super-admin role, but the built-in role presets are re-resolved against the live permission list on every boot, and the admin preset covers a content collection — so an admin does reach what they created, from the next start. The checklist asked only about the immediate seed and hid a step that was theirs to take.

    An API key was offered it on any grant beginning read-. The read decision admits a key on an exact read-<slug> match, so a key stamped read-settings could only finish the step by creating a collection called settings — a reserved name. It is now offered the step only where its grant names a collection that could actually be created.

    Both answers are computed from the declarations that produce them — the seeder's role and the preset predicates, and the collection slug rules themselves — rather than restated beside them, so a preset or a reserved name that changes carries the checklist with it.

    A widget source contributed by a plugin is now keyed from the record the registry published rather than by reading the plugin's object a second time. A JavaScript id may be an accessor or a proxy, and a second read that answered differently filed the resolver under an id no source claimed, leaving the published source failing every query as unanswerable.

    A widget source's resolver now receives the host's ResolverOptions — a cancellation signal today — beside the question it is answering. The dashboard's per-slot budget already stopped waiting for a resolver that hung, but a promise has no cancellation, so the work went on running and further requests started more of it. The signal is aborted when that budget expires, so a resolver that passes it to whatever it calls (fetch takes one directly) stops rather than merely stops being awaited.

    The parameter is optional and arrives in an options object, so every resolver written before it keeps working unchanged, and anything the host offers later becomes a field there rather than another positional argument. ResolverOptions is published through nextly and @nextlyhq/plugin-sdk alongside the resolver types.

  • The setup checklist offers "create your first collection" only to a reader who would be able to READ what they create.
    More

    A new collection's permissions are seeded to the super-admin role alone, so a caller holding the definition grant and nothing else created the collection, gained no read on it, and found the step outstanding permanently -- with the card pinned to their dashboard.

    An API key stamped with a read grant is offered the step it can finish: a key never gains a permission, but a pre-seeded read-<slug> lets it create that collection and read it afterwards.

  • An API key created by a Super Admin copies the permission catalogue rather than the role's rows.
    More

    A Super Admin's power is a bypass, and the role only holds the permissions that existed when the install was set up, so a key of theirs held a stale subset at best and, where the first user came before the grant, nothing: every request refused, starting with the first key an operator minted to try an integration with. A read-only key of theirs now reads every collection the install declares, including one added later, and still cannot write; a full-access key holds every permission. A permission a package stopped declaring is not inherited. Whether the creator is a Super Admin is asked of the same resolver as the session bypass, so a role built on top of Super Admin counts here exactly as it does everywhere else.

    A plugin calling ctx.services as a user now sees that user's roles. The caller was built with an empty role, so a code-defined rule such as req.user?.role === "editor" refused every caller on the plugin path while the same caller's own request passed it, and a negative rule granted what it was written to refuse. The roles are resolved and the caller is built by the one constructor every other authenticated path uses.

    Changing a permission row now retires the cached answers derived from it, and retiring them is no longer a separate step a writer has to remember: every write goes through one place that does both. None of the methods writing those rows invalidated anything, and neither of the existing invalidations can express the change, because a permission row belongs to no user and no role. So a role-based key kept a renamed slug, and a Super Admin's key kept a deleted grant and missed a new one, until their entries aged out.

    A cached answer resolved before an invalidation is no longer written after it. Every cache here is filled from an asynchronous read, so a lookup that began before a role changed could complete afterwards and put the old answer back into a cache that had just been cleared.

    A call whose caller's roles could not be read is refused rather than run as a caller with none, with a typed error rather than the driver's own. The resolver behind it degraded a failed query to an empty set, which is safe for a rule that grants on a role and wrong for one that withholds on it: user.role !== "suspended" admitted a caller the database declined to answer for.

    This covers an API KEY's roles as well as a plugin call's. A read-only or full-access key resolves its owner's roles, and those populate the scope every later role rule reads directly, so a failed lookup arriving as an empty set was indistinguishable there from an owner who holds no roles. Both paths now ask one resolver that refuses, rather than each deciding for itself what an unanswerable question means.

    Losing the Super Admin role now takes effect at once. The cached answer to "is this user a super admin" was not cleared when roles changed, so a demoted user kept the session bypass until the entry aged out, and an API key's grants resolved through that answer could be cached for five minutes of their own on top of it. Role and permission invalidation clears it, and an API key's cached grants are retired with it: they are derived from the same rows, and nothing retired them when a ROLE changed, so revoking a role's inherited Super Admin left a key holding the whole catalogue and changing a role's permissions left a role-based key holding the old set.

    A caller that arrived on an API key is judged on the KEY's roles, not its owner's, the way the REST path already judges one. A stored role rule reads the caller's roles directly, so the owner's roles let a viewer-scoped key minted by an administrator satisfy an administrators-only rule, and refused a key holding the very role a rule names because its owner did not hold it.

    Stored permission answers now expire after five minutes rather than a day. PERMISSION_CACHE_TTL_SECONDS still sets it. The stored tier is shared between instances and the signal that retires it is held in memory, so an instance that did not handle a role change goes on serving what it stored until the entry expires; the default was a day, which is not a bound worth having on a revoked grant. Installs running a single instance see slightly more cache misses and no change in behaviour.

    A batch of permission writes no longer defers an unrelated revocation. Seeding retires the caches once at the end rather than per row, and the batch is now scoped to the operation that opened it, so a revocation raised while a seeder happens to be running takes effect immediately instead of waiting for the seeder to finish.

    A permission answer computed while the caches are being cleared is no longer stored. Clearing the shared tier is itself a write, and a check that both began and finished during it could read a row the clearing had not yet reached and promote it, which put a retired answer back into a faster tier that outlives the clearing.

  • Renaming a class in the page builder could be refused without the author being told.
    More

    A host that answers immediately — because the site style is locked, or the name is one it already knows is taken — had its refusal discarded, so the row cleared and the rename looked like it had worked until the next read. A host that answered a moment later was always shown correctly; only the immediate answer was lost.

  • A text widget declares its prose and the dashboard draws it.
    More

    The archetype existed in the contract with nothing to carry the prose and no renderer behind it: a plugin declaring one got a card reading "not rendered yet". A text widget now declares content as markdown -- headings, paragraphs, lists, emphasis, inline code, block quotes and links -- and the host draws it through the same rich-text stack the editor uses, read-only, loaded only when a text card is on the dashboard.

    Three things the markdown cannot do, by design. Raw HTML is shown as the text it is. A link may point at http, https, mailto, tel, or a path on this site written as /..., ./... or #...; one to anything else is left on screen as the markdown it was written in, so the author can see it was refused. And the content is bounded at registration, and refused over the bound rather than cut: it travels inside every dashboard load for every reader offered the card. An external link opens in a new tab.

    content is required for text and refused on every other archetype, on both the registry and the plugin channel through one rule.

    The admin workspace payload now carries only the widget declarations its reader may see, and only the parts of them. A declaration is its whole content -- a text widget's prose, an actions widget's shortcuts -- so the gate a widget declares through requiredPermission, and the gate each of its actions declares, is applied on the server before the declaration ships, from the plugin channel and the registry alike, by the same decision the dashboard layout endpoint places cards with. Where two plugins contribute the same widget id, only the first declaration ships, which is the one the dashboard draws. Previously every authenticated caller received every declaration whole and the browser hid the gated cards and shortcuts. A registered widget the payload cannot carry -- one JSON drops or rewrites -- no longer wins a collision with a contributed one; the contribution stands, as itself, with the gate it declared. And a numeric character reference outside Unicode's range (&#1114112; and up) in a text widget's markdown now draws as the replacement character, as it would on a web page, instead of blanking the card.

    The dashboard layout's scope token now also covers which shortcuts inside the visible cards the reader may see, so the admin re-reads its workspace when a reader gains or loses a shortcut's permission and not only when a card appears or disappears.

  • Importing a design-token file reports what the reader passed over from the reader itself, rather than from a second walk of the file.
    More

    Two of the format's own fields now say what was lost: $extends (the group it would inherit from is not followed, so those tokens are not imported) and $deprecated. A token the importer refuses no longer also claims its value was read from $value.

  • A design-token import no longer calls an $extends that points through a $-prefixed key an inheritance, and a token refused for its name or identity still says when its $type was ignored.
  • A bulk create (createMany) and a create or update made inside a caller's transaction now write a fieldGroup field's value to its component table, as the ordinary create and update do.
    More

    Before, a bulk create of any collection that embeds a field group failed every row with no column named <field>, and a transactional update reported success while leaving the component's old value in place. A transactional update held as a working draft now carries the field group in the draft.

  • updateEntries on the collection entry service now takes overrideAccess, as createEntries already did: the collection gate, the publish-transition pre-resolve and every per-entry write skip access when it is set.
    More

    Before, a trusted batch update had no trusted path and was judged as an anonymous caller, so every row was refused on a collection whose update rule wants a user.

  • nextly --version reports the version that shipped.
    More

    The constant behind it was typed by hand under a comment saying it "should match package.json version". It did not: the CLI answered 0.1.0 while the package shipped 0.0.2-alpha.65, so anyone asking the tool which Nextly they were working against got a confident wrong answer — and telemetry attributed every CLI event to a version that has never been published.

    It is asked of the same resolver the plugin system already uses to validate a plugin's nextly compatibility range, so a reported version and a compatibility answer can no longer disagree. A test holds the two together.

    The scaffolded agent guide now says how to find that version and where the authoritative documentation is, including the two machine-readable indexes at /llms.txt and /llms-full.txt. An agent working in a Nextly project would otherwise answer from whatever it remembers of some other version.

  • A dashboard card that only matters sometimes had to hide itself.
    More

    The get-started card was placed in the grid, given an order, and then rendered nothing once seeding was done — so the arrangement reserved a slot for a card drawing nothing, and the reason lived in a component rather than in the declaration.

    A widget can now declare that it is transient: it names the condition it shows under, and the host stops offering the card once that condition lapses. Pinning and reader dismissal are not part of this release — a card cannot yet ask to sit above the ordinary order, and a reader cannot yet end one early — a reader who declines the get-started offer keeps its slot until the install has content.

    The condition is a NAME from a closed set the host owns, never a predicate or callback a widget supplies, so an onboarding surface cannot become the kind of unconstrained notice channel that other admin ecosystems have never managed to contain. A name this release cannot answer is refused when the widget is registered, where the author can still be told.

    The first condition asks whether THIS reader can see any content, and is deliberately about the reader rather than the install: answering across everything would report on rows the reader is not allowed to know exist. A draft counts as content — someone who has written one post and not published it is not looking at an empty install.

  • An API key's write appears in the activity trail.
    More

    It recorded nothing before — not mislabelled, absent. The recorder refused any actor that was not a signed-in person, because a row's identity column is joined to the accounts table and a key's own id would find no account and be filed as an already-erased person. So every write made with an API key was invisible, and nothing about it is recoverable after the fact.

    The row now carries the KIND of caller its identity column refers to, and the admin's Recent Activity names the key rather than showing a blank actor. user_id is unchanged and still required: it is already documented as the actor's opaque reference, so one nullable actor_type is the whole schema change and no dialect needs a nullability rebuild.

    A NULL kind means a row written before this existed. Those are all user writes, because no other kind was recordable.

    Writes with NO initiating actor — seeds, migrations, imports and jobs — are still not recorded, and the reason has changed rather than gone away. They run while the schema is being created, and a failure to write the trail fails the surrounding write: a trail insert against a table that does not exist yet would fail the seed that was creating it. That needs its own answer.

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

Packages

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

Earlier releases

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