Changelog
What's new in Nextly
Latest releases, features, improvements, and bug fixes.
v0.0.2-alpha.66
AlphaReleased 21 packages at 0.0.2-alpha.66 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #1792 `da323d5` Thanks @mobeenabdullah! - The schema builder's drag announcements name every field, and its row ids are
- only the ones the list mints.
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.
- #1697 `b87f0d3` Thanks @mobeenabdullah! - A dashboard card asserted a meaning its own data did not have. 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.
- #1756 `705926b` Thanks @mobeenabdullah! - The collections and singles cards no longer decide for themselves whether they
- have anything to show.
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.
- #1723 `4c03302` Thanks @mobeenabdullah! - The dashboard's setup checklist is answered by the server now, and shown only
- while there is something left to do.
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.
- #1770 `732da46` Thanks @mobeenabdullah! - 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.
- #1773 `716adbd` Thanks @mobeenabdullah! - 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. 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.
- #1827 `a6ea554` Thanks @mobeenabdullah! - A component can no longer be saved into a shape where the library references
- itself.
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.
- #1786 `a8f84fd` Thanks @mobeenabdullah! - A component the editor offers, and one the canvas draws, are now judged
- against the same forest and read from the same row.
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.
- #1683 `fda49c2` Thanks @mobeenabdullah! - A dashboard could count and group entries but had no way to show either as a
- picture. 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.
- #1821 `2fa4696` Thanks @mobeenabdullah! - A function default cannot read a field the caller may not write. A
defaultValuewritten 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.
- #1768 `2bcccdd` Thanks @mobeenabdullah! - Every sortable table in the admin can be reordered from the keyboard, and a
- screen reader is told what is being moved.
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.
- #1831 `b25490b` Thanks @mobeenabdullah! - A field hook now runs for the fields a write touched, and reads the whole row
- either way.
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.
- #1677 `793c9c1` Thanks @mobeenabdullah! - The engine's three whole-document measurements —
countNodes,treeDepthanddocumentBytes— now refuse a forest they cannot afford to walk instead of grinding through it or raising a native error.
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.
- #1706 `1bfc12e` Thanks @mobeenabdullah! - A spacing handle stopped following the pointer when a drag took a margin past zero.
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.
- #1863 `6e22e58` Thanks @mobeenabdullah! - 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.var(--\62 rand)isvar(--brand)to a browser, and is now read as the same working substitution.
- #1667 `48967c8` Thanks @mobeenabdullah! - 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. 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.
- #1733 `cadd25b` Thanks @mobeenabdullah! - A job handler is told which job it is running.
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.
- #1814 `83a2e49` Thanks @mobeenabdullah! - A many-to-many field's junction table is now followed by the table itself, not
- by the field that names it. 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
-
junctionTablerenames 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.
- #1676 `419d5f2` Thanks @mobeenabdullah! - A scoped API key is judged on its own grants at every access gate, not only the
- first one. 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.
- #1656 `9893b11` Thanks @mobeenabdullah! - A plugin route received the caller's account and nothing else. 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.
- #1695 `89f515b` Thanks @mobeenabdullah! - 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.
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.
- #1663 `5aae8fc` Thanks @mobeenabdullah! - Deleting a component that a Layout still uses is now refused, and the message
- names the Layouts to go and edit. 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.
- #1772 `c80d0da` Thanks @mobeenabdullah! - The collections and singles listings show exactly what the reader may open.
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.
- #1707 `9c8d64d` Thanks @mobeenabdullah! - 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. 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.
- #1856 `f6ddafa` Thanks @mobeenabdullah! - A field added to a collection now becomes the same column it would have become
- when the table was created.
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.
- #1817 `1ca2733` Thanks @mobeenabdullah! - A new package,
@nextlyhq/plugin-mcp, for exposing an install to AI agents over - the Model Context Protocol. 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.
- #1882 `dbebaf7` Thanks @mobeenabdullah! - Saving a published page no longer puts the edit live. 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.
- #1860 `ab3791d` Thanks @mobeenabdullah! - 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.
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.
- #1823 `5992aab` Thanks @mobeenabdullah! - A plugin can contribute a dashboard-widget DATA SOURCE.
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.
- #1684 `a2cd0f3` Thanks @mobeenabdullah! - A plugin can now serve a route at a top-level path instead of under
-
/plugins/<plugin-name>, by declaringmount: "root". 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.
- #1819 `39c96f3` Thanks @mobeenabdullah! - A plugin can update many entries in one call.
ctx.services.collectionsended atcreateMany: 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 ofupdateEntrycalls, 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 sameBatchOperationResultcreateManyreturns: partial success, witherrors[].indexindexing the array the caller passed. There is deliberately no by-filter form;listEntriesand 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. Alocaleis refused by name, as oncreateMany, because the bulk pipeline writes in one pass and cannot store a translation.@experimentalon the plugin surface until a first-party plugin exercises it.
- #1769 `4de79a2` Thanks @mobeenabdullah! - A plugin can now name the locale it reads or writes in.
ServiceOpts— the - options every
ctx.services.collectionscall takes — gainslocaleand -
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.
createManyrefuses any locale by name rather than filing the rows - under the default language silently; its bulk pipeline cannot store a
- translation yet.
deleteEntryrefuses one likewise: a delete removes every - translation with the document, and a locale on it would read as removing one.
- The selectors
*andallare refused outright, from one classifier now - exported as
isLocaleSelectorfrom@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
-
allornone, 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.
- #1673 `69b9aa5` Thanks @mobeenabdullah! - A plugin route can now ask what its caller may do, not only who they are.
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.
- #1679 `113f82c` Thanks @mobeenabdullah! - 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. 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.
- #1807 `348ceb9` Thanks @mobeenabdullah! - A database error from the adapters now says which operation failed on which
- table on every dialect. 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,insertordeletethere, 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.
- #1854 `e700886` Thanks @mobeenabdullah! - A reader can send a first-run card away from the card itself, and dismissing one no longer stops later widgets from reaching them.
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.
- #1840 `16efd9c` Thanks @mobeenabdullah! - A function default cannot read a denied sibling from inside a group or a repeater row either.
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.
- #1753 `08bce33` Thanks @mobeenabdullah! - 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. 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.
- #1752 `cde0bd0` Thanks @mobeenabdullah! - 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. The guard that recognised an
- outcome read one field of it — whether
okwas 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.
- #1757 `9ca0cfd` Thanks @mobeenabdullah! -
DropRegion.atis nowDropRegion.target. The module spelled two ideas with - one word: on a region,
atwas the placement a block would be nested into, - while on a
DropTarget,atwas a position andtargetwas that placement. - A host reading both had to remember which
atit held. The region now names - the placement as the target does, and
atmeans a position everywhere in the - module. A value still spelling
aton a region is a type error rather than a - silent fall-through; the package is
@experimentalthroughout, so no alias - is kept.
- #1834 `c52aa59` Thanks @mobeenabdullah! - An edit to what a relationship does when the row it points at is deleted now
- reaches the database.
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.
- #1796 `10ef8d9` Thanks @mobeenabdullah! - A many-to-many field's junction table now follows the field through its life in the migrations the builder writes.
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.
- #1868 `c5f8fe8` Thanks @mobeenabdullah! - Saving part of a page as a pattern no longer gives a block an id it never had.
- When a pattern is inserted beside an element that already uses one of its ids,
- the insert renames the copy (
pricingbecomespricing-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.
- #1857 `9bac152` Thanks @mobeenabdullah! - A rename of a site class survives the panel it was started from.
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.
- #1692 `dc38f47` Thanks @mobeenabdullah! - A plugin route can compute the permission it requires.
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.
- #1699 `c293917` Thanks @mobeenabdullah! - A dashboard chart's table draws its row separators at full strength.
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.
- #1875 `3d60b11` Thanks @mobeenabdullah! - Saving a component could do far more work than its bound said. 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.
- #1803 `4e25eb2` Thanks @mobeenabdullah! - A
single:<slug>widget source is executable.
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.
- #1826 `ebd0b24` Thanks @mobeenabdullah! - 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.
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.
- #1800 `e8e8d78` Thanks @mobeenabdullah! - A Single read now applies field-level
access.readrules before its field-levelafterReadhooks, and again after them, the way a collection read always has. 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.
- #1687 `adfafa3` Thanks @mobeenabdullah! - A spacing drag handle sat on the wrong edge for most margins, and dragged
- backwards there.
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.
- #1859 `812f167` Thanks @mobeenabdullah! - A publish that also edits a group, repeater or JSON field no longer fails validation.
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.
- #1832 `9ad1744` Thanks @mobeenabdullah! - 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.
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.
- #1830 `1532b2e` Thanks @mobeenabdullah! - The setup checklist offers "create your first collection" only to a reader who
- would be able to READ what they create. 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.
- #1783 `cd95546` Thanks @mobeenabdullah! - An API key created by a Super Admin copies the permission catalogue rather
- than the role's rows. 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-onlykey of theirs now reads every - collection the install declares, including one added later, and still cannot
- write; a
full-accesskey 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.
- #1747 `d29c54d` Thanks @mobeenabdullah! - Renaming a class in the page builder could be refused without the author being
- told. 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.
- #1782 `e1605f8` Thanks @mobeenabdullah! - A
textwidget declares its prose and the dashboard draws it.
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 (� 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.
- #1867 `0d5f838` Thanks @mobeenabdullah! - Importing a design-token file reports what the reader passed over from the reader itself, rather than from a second walk of the file. 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.
- #1885 `0708ec6` Thanks @mobeenabdullah! - A design-token import no longer calls an
$extendsthat points through a$-prefixed key an inheritance, and a token refused for its name or identity still says when its$typewas ignored.
- #1788 `9e59229` Thanks @mobeenabdullah! - A bulk create (
createMany) and a create or update made inside a caller's transaction now write afieldGroupfield's value to its component table, as the ordinary create and update do. Before, a bulk create of any collection that embeds a field group failed every row withno 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.
- #1801 `0121364` Thanks @mobeenabdullah! -
updateEntrieson the collection entry service now takesoverrideAccess, ascreateEntriesalready did: the collection gate, the publish-transition pre-resolve and every per-entry write skip access when it is set. 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.
- #1728 `06f192f` Thanks @mobeenabdullah! -
nextly --versionreports the version that shipped.
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.
- #1702 `2a6f497` Thanks @mobeenabdullah! - A dashboard card that only matters sometimes had to hide itself. 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.
- #1735 `8653c19` Thanks @mobeenabdullah! - An API key's write appears in the activity trail.
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/uicreate-nextly-appnextly
v0.0.2-alpha.65
AlphaReleased 20 packages at 0.0.2-alpha.65 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #1655 `1a1a13b` Thanks @mobeenabdullah! - Rendering a page that embeds components now refuses a document-limits object
- whose
maxNodesormaxDepthis not a number, instead of quietly rendering - without that bound. A cap that arrives as
undefinedorNaN— a partial - limits object, or one computed from an environment variable that is not set —
- did not fall back to the default: every comparison against it was false, so the
- walk had no stopping point and the guard that refuses to compose a document it
- could not survey whole never fired.
Sites that pass a complete limits object, or none at all, are unaffected.
- #1654 `df2843b` Thanks @mobeenabdullah! - A plugin can pass a hook context.
ctx.services.collectionsoperations take an optionalcontext, which reaches this operation's hooks asctx.context. It is how a caller tells a hook something about the CALL that the row cannot say.
Core has accepted this on every collection operation for a while and seeds the shared hook context from it. The plugin facade rebuilt its trailing argument as { user, overrideAccess } and dropped everything else, and CollectionService forwarded only those two, so nothing above could reach it.
It is data, not permission: nothing passed here bypasses access, validation or any hook. A hook decides for itself what to do with what it is told.
The form-builder plugin uses it for the case that prompted it. A submission write reads its parent form only to check the payload against that form's fields, and the form's afterRead hook was counting that form's submissions on the way past. That count is presentation work nobody on the write path reads, and it grows with the form's history, so every submission paid for a count of every submission before it. The write now says it wants the schema alone, and the hook skips the count.
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-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.64
AlphaReleased 20 packages at 0.0.2-alpha.64 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #1644 `b6984c1` Thanks @mobeenabdullah! - Converting a selection into a component threw a
RangeErrorwhen the caps - object passed to it kept its values on a prototype or as non-enumerable
- properties, rather than as plain own properties — a shape that worked before
- the previous release. It also ran any unrelated getter such an object carried,
- so a throwing one took the conversion down with it.
The three caps are now read by name, so how a caller chooses to store them no longer matters.
- #1606 `038bdfb` Thanks @mobeenabdullah! - A dashboard card stops showing itself as loading once its own data has arrived, instead of waiting for every other card on the page.
A dashboard asking for more than thirty widgets' data is split into several requests that finish independently, but every card was reading one page-wide "still loading" flag. A card whose own request had already answered went on dimming numbers it had, until the last unrelated request finished — most visible on the largest dashboards, where the split happens. Each card now reads the state of the request that carries it, which is also what the published WidgetComponentProps.isFetching describes and what a plugin author is told to use to tell a first load from a widget that asks for nothing.
- #1643 `ae5ea52` Thanks @mobeenabdullah! - Converting a selection into a component could be allowed on a site that raised
- the block document node limit, even when the selection already contained an
- instance of the component being created. The saved component then referenced
- itself, and where the content had been the page drew a missing-component
- placeholder instead.
The check that refuses a self-referencing conversion reads the selection under whatever node limit the site configured, rather than always under the built-in one. A site that never changed that limit was never affected.
- #1596 `af9f521` Thanks @mobeenabdullah! - A dialect nobody stated is now read from the connection URL.
DB_DIALECT carried a Zod default, so it was never absent, so the database
factory's URL fallback behind it could not run. Setting only
DATABASE_URL=mysql://... or file:./data/nextly.db, which the adapter
READMEs describe as enough, produced a PostgreSQL adapter, PostgreSQL
identifier quoting and the PostgreSQL schema tables, because all of those
read the same value.
The URL rules move to the environment schema, ahead of everything that reads
the dialect, so there is one answer rather than a second copy behind an
unreachable branch. An explicit DB_DIALECT still wins, and a URL that
implies nothing still defaults to PostgreSQL.
- #1617 `c4acdf0` Thanks @mobeenabdullah! - A widget query can now carry a group key, and the validator judges it together
- with the operation rather than separately.
groupBymeans something only to - the
groupByoperation, so a key travelling besidecountis refused instead - of being accepted and then dropped — an accepted-and-ignored key reads back to
- the caller as a grouped count that was never computed. A
groupByoperation - arriving with no key is refused for the matching reason, at the point that can
- still say which part is missing.
The key is read once, with every other property of the query, so an accessor
cannot answer one field to the check and another to the query that ships. It is
checked against the fields its source declares, the same set sort and
select are checked against.
Sources that answer one fixed question say so: keyof WidgetQuery drives an
exhaustive table in each, so a query naming a key they cannot honour is refused
by name rather than silently discarded.
- #1622 `56d7966` Thanks @mobeenabdullah! - A dashboard widget can now ask how many rows carry each distinct value of a
- field, and the answer describes exactly the rows a count of the same query
- would have counted.
Both reads settle that row set through one pipeline. Collection access, the readability guards, the read hooks, release scope, search, translation and component conditions, the caller's own filter and any constraint a stored access rule contributes are resolved once, and each operation only decides what to compute over what is left. An aggregate that assembled its own filters could describe a wider set than the count beside it, and the two would drift the first time a condition was added to one of them.
A group key is judged where a filter and a sort are judged, because buckets are the stronger disclosure: they publish the column's distinct values as the labels themselves, and redaction never sees them because no row carries the value. Grouping by a field with a read rule is refused by name. So is grouping by the owner column, which would report how much each author wrote, and a key that resolves to no column at all — dropping that would collapse every bucket into one row and answer with a single total that reads exactly like a real one.
Buckets are ranked and capped in the database once grouping is complete, so
the cap chooses among finished buckets and never changes which rows were
aggregated. When buckets are left out the result says so, for the reason a
bounded count says atLeast: a chart drawn from a silently capped set reads
as the whole picture.
The admin reads the grouped answer as its own result kind, so a card receives
buckets rather than the malformed-response error every unrecognised payload
becomes. A capped bucket set says so on the way through, for the reason a
bounded count carries atLeast.
A group key is refused wherever its values would otherwise escape: a field carrying a read rule under any spelling it can be reached by, the owner column, a password field — whose guarantee comes from its type rather than from an access rule, and whose row-level strip an aggregate never passes through — and a key naming no column at all.
For TypeScript authors, a widget's query now refuses at compile time to pair a group key with an operation that would ignore it, or to declare the grouping operation with no key.
- #1631 `2a84c21` Thanks @mobeenabdullah! - Media:
sizes,focalXandfocalYare returned bymedia.findByID()andGET /api/media/:id, not only by a media file populated as a relationship. Every variant URL is absolutized exactly asurlis, and the JSON string SQLite stores in the column is parsed, so all three dialects answer with the same shape.
- #1611 `d6e9f10` Thanks @mobeenabdullah! - A read against a collection that does not exist answers "not found" again, instead of a server error.
Both halves of the collection access check decided whether an error meant "this collection does not exist" by looking for the words "not found" in its message. The error the registry actually raises carries the text "Not found.", so neither comparison ever matched the one error it was written for — a genuinely missing collection produced a 500 from the permission gate, and, since a recent change, a rejected read from the constraint resolver. Both now ask the error what it is rather than reading its prose, so the case they were written for is the case they catch, and an unrelated failure whose wording happens to contain those words no longer takes the exit.
The registry that raises it now says so by type as well as in words. It threw a bare error, so every caller had to read its wording to learn what had happened, and a guard that asked by type instead missed it entirely. It now carries a not-found code alongside the same message, so a caller may ask either way and neither answer changes.
- #1637 `cdf9de9` Thanks @mobeenabdullah! - A toolbar verb added later cannot silently delete a block.
The bar's dispatch was a chain of else if ending in a bare else verbs.delete(), so a verb added to ToolbarActionId and not wired to a handler did not fail to compile — it fell through to the last arm and removed the block the author had selected.
Measured, the way it would actually have happened: adding an id makes the icon map fail with TS2741 and left the dispatch silent, so the compiler pointed at the missing icon, a developer supplied one, and the new button then deleted things. The dispatch is a Record over the verb set now, so a new verb fails to compile there too — verified by adding one, which raises two errors where it previously raised one.
- #1639 `6c0b7ff` Thanks @mobeenabdullah! - An author could browse the pattern library and never put anything in it. The
- planner that turns a selection into a stored pattern had no caller, so the
- Patterns tier shipped with nothing to show and no way to add to it.
The page builder now contributes the write that fills it. The editor posts the document and the selection, and the SERVER plans the save — because the planner decides what a pattern is by asking the block registry, and the two registries are not the same: the browser holds the core blocks, while the server also holds every block another plugin declared. A browser that planned its own save would answer nesting questions about blocks it has never heard of, and store a pattern nothing can place.
The row is created published, because a draft pattern is deliberately kept out of the insert panel — leaving the column's default would answer the author with a pattern their own library does not show. The write runs as the user, so an author without permission to publish one is refused by the collection rather than by the route having been careful. A selection the planner will not save is refused before anything is written, with the planner's own cause travelling verbatim so the caller compares against the vocabulary it already has.
- #1632 `9b60af4` Thanks @mobeenabdullah! -
usePluginRouteMutationis new on@nextlyhq/plugin-sdk/admin. A plugin could READ its own contributed route and had nothing to write to it with, so any feature that saved something was back to hand-rolling the session, its refresh and the error envelope — the exact problemusePluginRoutewas added to remove, left standing on the other half of the same seam.
It goes through the same authenticated client and the same query cache the admin's own writes use. Four things a consumer should know:
The plugin names ITSELF, as it does for the read, because nothing in a plugin component's React context says which plugin contributed it.
invalidates names the plugin's own read paths, so a write refreshes the list it belongs in. Named rather than inferred: nothing in the admin knows which reads a write affects, and that is the plugin's own knowledge. Each path is resolved through the calling plugin's name, so one plugin cannot invalidate another's cached reads however it spells a path.
write RESOLVES on failure rather than rejecting, answering undefined, and reports the cause on error. A rejecting promise is the idiomatic TanStack shape and a footgun on a surface handed to plugin authors: a caller who does not wrap the await gets an unhandled rejection for a failure that is already reported. It is the shape the read hook has, so there is one thing to learn rather than two.
Nothing is toasted from the hook. The admin's own mutation hooks raise a toast because they own the surface that follows; a plugin owns its own, and a generic hook that announced every write would put the admin's voice inside someone else's feature.
A plugin write is never retried automatically, which overrides the admin's own default of two. The admin retries its own mutations because they address routes it owns and knows the shape of; this addresses a route a plugin wrote, with no idempotency key and no requirement that the route be idempotent, and the default verb is POST. A create that commits and then loses its response would be sent again — twice — and an author gets three rows for one click with nothing reporting it. The asymmetry decides it: a write that is not retried costs a failure the caller is told about and may repeat deliberately; a write retried wrongly costs duplicate data nothing can identify afterwards.
The target travels with the body. A write paused offline has its options updated before its retryer runs, so a closed-over route would send a body submitted against one endpoint to whichever the hook was rendered with by the time the connection returned.
Every write is reported, not only the newest. TanStack's observer follows the most recent call, so with two writes in flight — a double submit, an autosave overlapping a save — the older one's rejection reached no observer: error never saw it and pending went false while that request was still running.
TBody DEFAULTS to JsonValue rather than being constrained by it. A constraint rejects an ordinary interface SaveBody { title: string }, because an interface has no implicit index signature in TypeScript — the error lands on correct code and the only fix is "rewrite your interface as a type alias", which teaches nothing about serialization. That is the reasoning this codebase already recorded for clientConfig, and the constraint contradicted it. Every sender applies JSON.stringify, and the docblock says so.
A write may carry no body at all. A contributed DELETE /items/:id legitimately has none, and inventing one — null, {} — is a different request that a handler requiring an empty body can reject.
A write refreshes the reads it was SUBMITTED with. The target was already snapshotted; the invalidation keys were not, so a write held while the hook re-pointed refreshed the new selection's reads and left its own stale.
A later success clears an earlier failure — but only one submitted no later than itself. error reports the last write, and left set, a plugin showed "could not save" beside a save that had just worked. Clearing it unconditionally is the same defect pointing the other way: a slow earlier save completing after a newer one has already failed would erase that failure, and the surface would report success for the write the author cares about most. Failures carry the submission that produced them, so an older success cannot speak for a newer write and a newer failure replaces an older one.
The write verbs are Exclude<RouteMethod, "GET"> rather than a second list of methods. Spelled out, that was a narrower view of the route contract that would stop covering it the moment a method was added: a plugin could declare the route and this could not call it, and nothing would fail. RouteMethod is published from nextly/config for that, beside pluginRouteFullPath and for the same reason — the admin has to agree with the dispatcher about what a route is.
protectedApi.delete now sends a body the caller SUPPLIED rather than one that is truthy. false, 0, "" and null are valid JSON, and a truthiness test dropped all four, so delete was the one verb that silently disagreed with what it was passed. Measured before changing it: no caller passes a body at all today, so nothing that exists sends one where it did not before.
The result type is bounded to an object or null, for the reason the read is: the admin's fetcher returns undefined for a bare string or number, so Response.json("ok") would arrive as a successful empty answer and a caller typed <string> would silently never see it.
- #1627 `3596c69` Thanks @mobeenabdullah! - A plugin route's answer is private, and its body is left alone.
Two properties every contributed route needs and no plugin author could supply: both headers are internal to nextly, so a plugin wanting either would have to hardcode a private string. They are applied where every plugin response already converges.
A plugin's JSON is no longer rewritten on its way out. Every JSON response passes through the framework's timezone normalisation, which walks nested values and rewrites any string matching its ISO pattern BY VALUE, whatever the key is called. A plugin's body is whatever that plugin defined and the framework knows nothing about its shape — so a block prop or a description holding text like 2026-09-08T12:34Z reached the browser already rewritten, and inserting then saving it persisted content the author never wrote. That is silent corruption of stored content, and it is the same reason a webhook delivery's captured text opts out: opaque text has to survive verbatim. A plugin that wants timestamps normalised can normalise them; a plugin whose text is altered has no way back to what it stored.
An authenticated plugin route now says its answer belongs to one session. Secure-by-default decides the auth; this is that rule reaching the cache. A route that required a session answers from that caller's own access, so a shared proxy could retain one authorized response and serve it to the next request without the authentication check running again. It is applied to the REFUSAL as well as the answer — a cached 401 replayed to a request that does carry a session is the same defect pointing the other way, and it is the direction that looks like a working gate. A public: true route is deliberately left cacheable: it serves the same bytes to everyone, and forcing no-store would throw away caching it is entitled to.
The headers are rebuilt rather than set in place, because a handler may return a response whose headers are immutable — one that came from fetch, say — and setting a header on that throws, turning a marking step into a 500.
The privacy headers are MERGED into what the handler already said, not written over it. Replacing Vary was the sharp edge: a response varying on Accept-Language became one varying only on Cookie, so a cache could answer a second language out of the first one's stored copy — the same session, the wrong representation. Existing fields are kept and Cookie is added, and Vary: * is left alone because it already means "vary on everything". Cache-Control keeps every directive that is orthogonal to privacy — no-transform still forbids a proxy rewriting the body whether or not a cache may store it — while the ones that contradict no-store (public and the freshness family) are dropped rather than left to be resolved by whichever rule a cache prefers.
The internal markers no longer reach a client. The response boundary returned early for a non-JSON body BEFORE removing them, and the only other place either marker comes off is downstream of that return — so a plugin answering with CSV or XML, which an export or a sitemap route does, carried an internal control header all the way out. They are now read first, removed unconditionally, and acted on afterwards; removing them before reading would silently turn every opt-out back on.
withSessionCacheHeaders moved to api/response-shapes and is re-exported from routeHandler, so every existing importer keeps its path. The values now have ONE definition that both the response-owning callers and the plugin dispatch read, rather than two that agree until someone edits one.
- #1614 `8af6e67` Thanks @mobeenabdullah! - A field a caller may not read can no longer be used to group results.
Filtering and sorting by such a field were already refused, because both reveal the hidden value: filtering through which rows come back, sorting through where they land. Grouping reveals it more directly than either — the distinct values become the buckets themselves, so the whole value set can be read off the labels while field redaction strips the column from rows nobody looks at. Selecting the field is not required for that, so guarding the selection would not have closed it. Grouping by a field that carries a read rule is now refused with its own reason, alongside the existing two.
- #1616 `ffa0e3c` Thanks @mobeenabdullah! - An author can insert a saved pattern.
The insert panel has accepted saved patterns since the tier landed, and nothing in the product supplied any — so a pattern could be saved and never seen again. The page builder now serves its library from a route of its own and hands it to the panel.
Published rows only — and NOT by saying so. The read runs as the user, and an untrusted caller that states no lifecycle already gets public states only, asked of the collection's own WORKFLOW. A literal status: "published" looked like the same thing and was not: it is ANDed with the service's release-aware condition, so it re-hid a draft belonging to a release whose time had come but whose drain had not run, and "published" is a state NAME, so a collection whose workflow calls its public state anything else would have matched nothing and come back empty.
The route is authenticated and declares NO permission, which is deliberate. A declared one has to spell the collection slug, and a host may rename that collection — the seeded grant then carries the new name while the route demands the old one, which is a route nobody can call. The read runs as the user instead, so the service enforces whatever permission the collection actually seeded, under whatever name it actually has.
The wire shape EXTENDS SavedPattern, the type the panel actually reads, rather than describing the same thing again. Described separately they disagreed about one field name — the wire carried content, which is what the collection stores the tree under, while the panel reads document and SKIPS a pattern that has none — so every pattern on every site was dropped in silence and the tier looked wired and empty. Extending the published type makes that a compile error.
A full-page pattern is left out of the insert list, and it is asked as a CLOSED question. granularity !== "page" answers true for everything it has never heard of, including a value that is MISSING — the field is required on the collection, so absent means an afterRead hook or a field-level read rule removed it, and a page pattern whose granularity was stripped was offered for insertion inside the page it is meant to be. The insertable granularities are now named, so anything unrecognised is refused; refusing wrongly leaves one pattern out of a list, while allowing wrongly puts a whole page inside the page an author is editing.
PATTERN_GRANULARITIES moves to the wire contract, which is where both ends can read it: the panel runs in a browser and cannot load the collection module, because that reaches the framework's field helpers. The collection reads its options from the same list, so there is still one vocabulary — and the rule is a Record over it, so adding a granularity is a compile error until someone classifies it rather than a value that silently becomes insertable.
The response ceiling now reserves the ANSWER's framing rather than only the rows inside it. Response.json wraps the rows in {"items":[…],"meta":{…}} and separates them with commas, so a library whose rows totalled exactly the ceiling left the server above it — measured, nine bytes over — and a proxy limit set at the same figure rejects a response this route believed it had bounded.
A full-page pattern is left out of the insert list. It is a way to START a page rather than something to place after the selected block, and SavedPattern carries no granularity, so nothing downstream could tell one apart.
It is bounded three ways, and only one of them is a row count. A ceiling counting patterns KEPT is never reached by a page whose every row the reader had to drop, so the READS are bounded too. And BYTES, which no count can bound: one valid document may be two mebibytes and a host may raise that, so three thousand of them is gigabytes assembled on the server and then sent to a browser, from a request an author makes by opening the editor. Whether another page exists is the SERVICE's answer rather than a length this recomputes, because an afterRead hook can shorten a page without the collection having ended. The ceilings apply PER ROW, because one checked between pages bounds nothing about the page being read: a single page of a hundred two-mebibyte documents is two hundred mebibytes already assembled, and a library that ends there would have been reported complete. Each row is weighed BEFORE it is kept, so the byte ceiling is an upper bound on the response rather than a line its last row is allowed to cross — measured, appending first and checking after returned 17.8 MB against a 16 MiB ceiling, and a pattern larger than the whole budget came back whole. One that fits in no budget is left out and the read goes on, so the patterns behind it still arrive.
The WHOLE ROW is charged, not its document. Charging the document meant charging the one field that happened to have no bound of its own, and description is a textarea with no length either — so a library of long descriptions and absent documents scored exactly zero and no ceiling was ever consulted: measured, sixty such rows serialised to 24.0 MB against a 16 MiB ceiling. The weight comes from the engine's measureBytes, the same survey the canonical validator asks its size question through, so this agrees with the ceiling a stored document already passed. It counts UTF-8, which is what a byte means on the wire — String.length counts UTF-16 code units, so CJK text weighed one third of what it costs and passed roughly three times the nominal ceiling — and it is bounded, so weighing an oversized row does not itself cost its size.
A row that cannot be SERIALISED is dropped rather than counted as free. An afterRead hook may hand back a document holding a bigint or a cycle; counting that as costing nothing kept it, and serialising the assembled library then threw — so one malformed row answered the author with a failed request instead of a shorter list. Reaching any ceiling is reported rather than silently truncating a library the author would then search in vain.
The pages are read in a deterministic order. They are independent offset queries and the service adds ORDER BY only when a sort is asked for, so an unordered read is free to return rows differently per page — one pattern arriving twice and another never at all.
usePluginRoute is new on @nextlyhq/plugin-sdk/admin. The two halves of a plugin could not reach each other: a plugin may serve an HTTP route and may render admin components, and there was no client for the second to call the first with — so an author's choice was to hand-roll the session, its refresh and the error envelope, or to read something else instead. It goes through the same authenticated client and query cache the admin's own reads use. Three things a consumer must know: the plugin names ITSELF, because nothing in a plugin component's React context says which plugin contributed it; the path is built through the dispatcher's own pluginRouteFullPath, so a caller cannot address a namespace the server does not serve — a mistake that does not raise, since a request to a path nothing serves answers with nothing; and pending is a real third state, because undefined is both "the route answered nothing" and "the route has not answered".
pluginRouteFullPath is published from nextly/config for that reason, beside pluginAdminSlug and for the same one: a slug derived twice produces a dead link, and a route path derived twice produces a request to a path nothing serves. SavedPattern is published from @nextlyhq/builder, which is the shape a host has to supply for the panel to offer patterns at all — including the two absences that are not interchangeable, keywords and content, each of which arrives as null from a stored row rather than missing.
The library is read when the insert panel OPENS, not when the editor mounts: the shell renders only the open panel, so the read lives in a component mounted with it. An author who works in Layers, or opens no panel at all, never pays for a library they are not looking at — and opening the panel is exactly when someone expects to see a pattern they just saved.
usePluginRoute reports a read waiting to RESUME as pending. Offline before the first request, TanStack holds a query at isPending with fetchStatus: "paused", so isFetching is false while nothing has arrived — and a surface that read it as settled would draw its empty state over a request that has not happened yet.
Its body type is bounded to an object or null. The admin's fetcher returns undefined for a bare string or number, deliberately, because no endpoint in the admin answers with one — reasoning that holds for the admin and stops holding for arbitrary plugin routes, where Response.json("ready") would arrive as a successful empty answer. usePluginRoute<string> no longer compiles; a route wanting a scalar wraps it, which the canonical envelopes do anyway.
usePluginRoute carries a successful EMPTY answer as success. A 204, a 205 or a zero-length body reaches the hook as undefined, and TanStack rejects undefined query data outright — so a route that legitimately answered with nothing reported a failure to a plugin that had done nothing wrong. null stays distinct from no body at all.
usePluginRoute also takes an optional staleTime. The admin holds a query fresh for five minutes and does not refetch on focus, which suits lists whose writes invalidate their own keys — and a plugin route is not on that map, since nothing in the admin knows which routes a given write affects. The pattern library asks for 0, so an author who saves a pattern and then opens a page is not shown a library their own save is missing from.
- #1630 `e18ae9d` Thanks @mobeenabdullah! - A session-gated response names every credential it depends on, and keeps a quoted cache directive whole.
Vary names the API key as well as the cookie. A non-public plugin route accepts Authorization: Bearer — requireAuthentication resolves an API key to its own user, roles and permissions — so two different keys had the same cache key while their responses legitimately differ. For any intermediary that stores despite no-store, which is the fallback this header exists for, the first key's answer could be replayed to the second. Both credentials are named now.
A quoted Cache-Control directive is no longer split down the middle. private="Set-Cookie, X-User" is one field-qualified directive, and splitting on every comma made it two: the first was discarded as private and the second survived as the fragment X-User". Measured on that input, the emitted header was private, no-store, X-User" — malformed, with an unbalanced quote, which a strict intermediary may reject along with the privacy directives it was carrying. Members are now separated only on commas outside quoted strings, with backslash escapes honoured, so a quote written inside a value does not end it.
- #1636 `2b922fe` Thanks @mobeenabdullah! - Form submissions are transformed, sanitized and validated on a
beforeChangehook on the submissions collection, the last collection-level mutating phase before the insert, so every path that writes one gets the same treatment: the built-inPOST /api/forms/:slug/submit,nextly.forms.submit(), a host route callingsubmitForm, the admin, an update that replaces a stored payload, and an update that moves a submission to a different form, whose stored payload has to satisfy the schema it lands on. Those paths previously stored whatever the caller sent, because core skipsjsonfields when it sanitizes: undeclared keys, values that never met the form schema, and markup intact.
Sanitizing now runs before validation, so a rule judges the value that will actually be stored. <b></b> no longer satisfies a required field and then reduces to an empty string.
Stripping markup no longer removes text that only looks like a tag. A < opens a tag only when what follows could name one, which is the HTML tokenizer's own rule, so an answer containing 2 < 3 survives intact. Stripping is a single pass that keeps one invariant: a < it kept is never followed by a character that would open a tag. Removing a tag can put its neighbours together into another one, and rescanning until the text stopped changing held the same invariant at quadratic cost, which an unauthenticated caller could spend the server's CPU on.
validateSubmission asks the same rule rather than restating it, so a preflight check and the write can no longer disagree about the same submission.
A submission flagged as spam is stored without being validated so a false positive stays reviewable, and that exception now belongs to a row rather than to a call: it travels as a symbol key on the row itself, which a request body cannot carry and a second write cannot take. Marking a row "Not spam" checks the payload it carries against the form.
Moving a submission to another form re-projects its answers onto that form's fields, and that change is stamped as an edit, because the visitor's stored answers changed and nothing else records it.
A parent-form read that fails is no longer reported as a validation error. Only a form that is not there is; a pool timeout or a throwing hook propagates, so a server fault stops arriving as the writer's mistake.
Spam protection stays on submitForm, where a honeypot and a rate limit are facts about a request rather than about a row. The built-in submit route does not reach it, and the guide now says so.
- #1641 `aa78ddc` Thanks @mobeenabdullah! - A dashboard widget can now ask how many rows fall in each interval of a recent
- window, ready for a trend line. `nextly.timeseries({ collection, dateField,
- interval })
buckets by hour, day, ISO week, month or year, and thetimeseries` - widget op reaches it.
A timeline is a grouped read whose key is a bucketing expression over a date
rather than a second aggregation path, so it settles which rows a caller may
read through the same pipeline a count and a bucket set use. One expression is
built per dialect and used in the SELECT and the GROUP BY alike, which is what
MySQL's only_full_group_by requires, and all three databases answer the same
text for the same instant.
An interval with no rows comes back as zero rather than being left out: a
GROUP BY cannot report a bucket it never grouped, and a line drawn through the
gap reads as steady activity rather than none. Buckets are computed in UTC, so
the same row lands in the same interval whoever is looking, and the window
bounds the read itself rather than only the answer.
Grouping by a decimal field now labels its buckets to the scale the field
declares. SQLite reads a numeric column back as a JavaScript number while
PostgreSQL and MySQL return text, so the same stored value used to arrive as
1 on one adapter and 1.00 on the others.
nextly.group and nextly.timeseries are now documented, including that text
buckets follow the database's own collation — grouping and filtering therefore
agree with each other on every install.
- #1600 `334e59b` Thanks @mobeenabdullah! - A plugin author can now name the types their dashboard widget receives, and the server, the admin and every plugin share one definition of what a widget query answers.
@nextlyhq/plugin-sdk/widgets publishes WidgetComponentProps — the five props a widget component is handed — alongside the wire shapes WidgetSlot, WidgetResult, WidgetResultField and WidgetQueryBatchResponse, which nextly/widget-result now exports from the module that declares them. Both are leaf entries carrying no runtime, so importing a type pulls no code. Until now those shapes were declared three times — by the endpoint that sends them and again by the admin that draws them, with the admin's copy the stricter of the two: it promised readers that a successful slot carries a result and a failed one carries a reason, while the server's own type required neither. The endpoint now declares that union itself, so the compiler holds it to the guarantee its consumers were already relying on.
- #1640 `fba5a6c` Thanks @mobeenabdullah! - An author could browse the pattern library and never put anything in it. The
- verb that stores a selection did not exist on any surface, so the Patterns tier
- shipped with nothing to show and no way to add to it.
Save as pattern is now one of the block verbs, so it reaches the toolbar, the
right-click menu and the command palette the way every other verb does. It is
offered for a run of blocks as readily as for one, because a pattern is usually
several — a heading, a paragraph and a button — and it is disabled with a reason
whenever the planner would refuse the selection, asked of the planner rather
than restated, so the button and the save can never disagree.
The form asks for a name and for how much of a page the pattern covers — an element, a group of blocks, or a whole section — with every option and its meaning visible rather than behind a picker, because the choice is required and has no default. It offers the granularities the insert panel can offer back, which is why a whole-page pattern is not among them yet: that one is a way to START a page, and the surface that would offer it does not exist. Category suggestions come from what the library already uses, so one site does not grow "Hero", "hero" and "Heroes" as three groupings nobody chose. Nobody is asked for a slug.
A refused save keeps the form open with the draft intact. A name collision is the expected failure and the remedy is to change a field that has to still be on screen.
- #1582 `6b1fa82` Thanks @mobeenabdullah! - Add
nextly/document-lock, a client entry carrying the document lease contract - and its wire types and nothing else, and the admin-side hook that holds a claim
- against it.
The entry is separate from the root one because the admin maps nextly to this
package's source, so reaching two constants through the root pulls the DI
container and the auth middleware into the admin's typecheck, measured at 112
errors about code it never touches. The timings are re-exported rather than
restated: they are the agreement between a lease and whoever renews it, and a
second copy of either number drifts from the first the moment one is tuned.
deriveLeaseTimings moved out of database/lease-clock into
database/lease-timings, which imports nothing. The clock module asks the
database what time it is, so it loads the ORM at module top level, and anything
reading the timings through it put that ORM into the import graph of every
client that needed a number. A test now walks the whole module graph a
browser bundle pulls in, starting from every admin "use client" module and
crossing into workspace packages, and fails on one that can reach a database
package. It reads source rather than a build, since a bundler can hide a leak in
a conditionally-loaded chunk and a check that reads dist passes whenever
dist is stale.
@nextlyhq/module-specifiers gained moduleSpecifierRefs, which reports for
each reference whether it survives to runtime, and importedSpecifiers is now
derived from it rather than walking separately. The boundary above needs that
distinction: an import type is erased before anything runs and cannot put code
in a bundle, and counting one would fail the rule over correct code. Reading the
richer answer from the one walk is also what stops the two drifting, which is the
defect that reader was written to fix.
No editor mounts the hook yet, so nothing about using the admin changes in this release. What ships is the mechanism the editor work builds on: the claim is held for one document at a time, every reply is fenced on the claim token that produced it rather than on the effect that sent it, a claim acquired after the editor has gone is released rather than left for the lease to reap, and a run of failed confirmations is treated as a blip until the lease's own loss deadline and as a loss after it.
- #1593 `c97af6b` Thanks @mobeenabdullah! - An editor is now told when a colleague is already in the document they opened,
- and can read it or take it over.
A strip above the document names the holder, and the fields below it render uneditable while somebody else has it. Both are needed: rendered read-only the fields are legible but ambiguous, since a tinted uneditable form reads equally as a document this account lacks permission to change.
The claim is advisory throughout, and what follows from it is derived in one
place so the collection editor and the single editor cannot disagree.
- Every write passes one gate. Save, Publish, Unpublish, Delete, discarding a
working draft, the keyboard shortcut, a native form submit and the quick-edit
modal all reach the same handlers, so the refusal lives there. The controls are
disabled as well, because nothing should offer what it cannot do, but disabling
affordances one at a time is a list the next write path gets added without.
- The title and the slug are writes too, and the same claim withholds them.
The title also drives the slug, so leaving it editable contradicted the strip
above it.
- Asking does not block editing. Gating every document open on a round trip
would cost every author on every open, to guard against a case that is rare,
and a refusal loses nothing since the form is never cleared.
- A failure to re-check a KNOWN claim does not unlock the document. Every beat
re-asks, so a transient rejection arrives long after a holder was reported;
treating that as "free" hands the document to a second editor while the last
confirmed fact is that a colleague holds an unexpired lease. Only a first check
that never succeeded leaves the editor working.
- A displaced editor keeps what they typed. Their unsaved work stays on
screen and stays theirs; what stops is writing.
- Autosave keeps running, which is the opposite of what it looks like it
should do. useDocumentAutosave does not write the document: it upserts a
recovery row keyed by document AND author that the live-row predicate excludes,
so it cannot reach the holder's document or their recovery row. Stopping it
would remove the displaced editor's safety net at the exact moment the banner
promises their unsaved changes are still theirs, and the engine depends on it
running.
The strip is where the lock is spoken. DocumentStatusLive is deliberately not
given a second copy: it exists so the header has one live region rather than one
per concern, and two in a view interrupt each other.
- #1610 `e6797c5` Thanks @mobeenabdullah! - A collection read rule that cannot be evaluated now refuses the read instead of resolving to no restriction at all.
getAccessQueryConstraint answers null for "allowed, with nothing to narrow", and callers fold that into the query as the absence of a filter — so swallowing a failure into null removed the rule's narrowing and returned every row. The gate beside it evaluates the same stored rules and already fails closed on an unexpected error, "for safety" in its own words, so the two disagreed about what a failure means. They now agree. A missing collection keeps its existing passthrough, which the read paths report as a 404 rather than as an authorization decision.
In practice both read paths run that gate first and it denies before this is reached, so no shipped read is known to have widened. The value is that the guarantee stops depending on a caller remembering to ask the other question first.
- #1634 `e6c1b48` Thanks @mobeenabdullah! -
saveAsPatternRefusalis new on@nextlyhq/blocks-engine. The counterpart topatternRefusal, published for the same reason and against the opposite mistake: that one stops a palette OFFERING a stored pattern the planner would reject, and this one stops a surface offering to SAVE a selection the planner would reject — a button that accepts a click and then fails.
Before it, the only way to ask was to call planSaveAsPattern with a target invented for the purpose — a collection name and a field set that a surface deciding whether to _offer_ the save does not have yet. So the question could only be asked by answering a different one.
It is a thin view over the planner's own preflight rather than a second walk: the same plannedSave both save planners call, so a question answered here and a save attempted afterwards cannot disagree. The ways a selection can be unsavable are not a short list a toolbar should keep its own copy of — not one contiguous run, a block that may not be a document root, a node whose shape the op layer will not carry, a descendant nested where the rules no longer allow, one DOM id on two of the run's own nodes — and a surface enumerating them drifts silently the first time the planner learns a new way to say no: the button stays enabled and the save fails.
It takes no limits of its own: planSaveAsPattern has none either, so a preflight that accepted them would answer a question the planner never asks — a lower cap disables a save the planner then accepts, a higher one the reverse.
A save is now refused when the document it would store exceeds the byte, depth or node caps. The blocks field validates what it is handed, so such a pattern was rejected at the WRITE — after the author had named it, filled the form and pressed save. The same verdict now arrives at plan time, where they can still act on it, and it is what lets the preflight promise that a save it permits will not fail on size. This applies to the save planners as well as the preflight, because both ask one shared question.
Because it does the work a save does up to building the stored document, it is exact, and it is worth memoising on the selection rather than calling per render.
- #1647 `92b8005` Thanks @mobeenabdullah! - Plugins and hosts can now ask which components a block document references
- AND whether the whole document could be read, through
componentUsageIn.
The existing componentIdsIn answers only the first half: it walks under a
node budget and stops silently, so a document too large to read whole returns
the same empty list as one referencing nothing. That is the wrong way round
for anything deciding whether a component is still in use, because "references
nothing" is the answer that allows deleting it. componentIdsIn keeps its own
signature and result, and is now derived from the richer answer, so the two
cannot drift apart.
componentIdsIn and componentUsageIn now refuse a maxNodes of NaN
instead of walking without a bound. NaN never satisfied the stop test, so
the budget was not merely loose, it was absent — a document of any size was
read whole. Any other numeric budget behaves as before.
- #1599 `02efedc` Thanks @mobeenabdullah! - Claim a document only when the caller may actually update that row.
/api/document-lock authorized writes on update-<slug>, which is the coarse
route permission: it says a caller may update documents of this kind, not that
they may update THIS one. A collection carrying an owner-only or role-based
stored rule refuses the row while that permission still stands, so a non-owner
could take a claim on a document every real update denies them, and the
legitimate owner was then shown a false holder and pushed to take over their own
row.
The gate the version routes already run for the same reason now runs here too. It
was named assertVersionDocumentUpdatable and is renamed to
assertDocumentUpdatable, since it was never about versions: its own docblock
describes the document's update rules, and it now has two callers.
- #1648 `147c86b` Thanks @mobeenabdullah! - The cold-start guidance on
resolveContentandcreateSingleRoutenamesgetNextly({ config })again. The accessor rename swept those doc comments along with the code, and they had been recommending the one function that cannot do what they describe:requireNextlyreads the already-registered singleton and throws when nothing has booted, which is exactly the case the guidance is about.
- #1603 `f229eb3` Thanks @mobeenabdullah! - Give a custom collection edit view the same document claim the default editor
- takes.
A collection registering admin.components.views.Edit.Component returns its own
view before the entry form mounts, and the form is where the default editor
claims. So those views announced nothing to the lock: a colleague opening the
same document was told nobody held it, and the editor was shown no holder and
offered no takeover, on precisely the documents a project cared enough about to
build a bespoke editor for.
The claim is now taken above the branch, and the lock banner renders beside the scheduled-release banner already there, which the page renders on that branch for the same reason: a custom view replaces the form, not the facts about the document.
The view is also handed what the claim withholds, through the props every custom edit view already receives rather than an admin-internal hook it cannot import. The banner says in words that unsaved changes cannot be saved while a colleague holds the document, and a view that wrote anyway would make that sentence false and would overwrite the holder's row. The bundled form builder reads it, so its save is withheld and its Save button says so.
Both halves of the claim, not only the save. The strip says the editor may read this document and not change it, so the builder's state withholds every action that would change it while a colleague holds it, and keeps the ones that only move around: selecting a field, switching tabs. The refusal sits once at the state every control reaches rather than at each of the dozens of controls, and it withholds every action except the ones named, so one added later is covered without anyone remembering.
The claim is taken only once the document is actually on screen. Taken while the entry is still loading, or after it failed to load, it would heartbeat a document the editor is not looking at, and colleagues would be told this person is editing a page that never appeared for them.
Only that branch claims. The form still claims for the default editor, and claiming in both places would put two claims on one document under one author. The condition is the resolved component rather than the registered path, because a path that resolves to nothing falls through to the form, which has its own claim.
- #1607 `c8ddba4` Thanks @mobeenabdullah! - Publish the custom edit view contract, and give the form builder the lock type
- rather than a copy of it.
A plugin registering an Edit view is handed \CustomEditViewProps\ and had no way
to type against them: the interface was not exported, so the bundled form
builder declared its own \documentLock\ shape beside the one the admin passes.
Both compiled, and an affordance renamed in the admin would have gone on
compiling on both sides while quietly no longer reaching the write gates that
read it.
The pair the admin hands over is now derived from the affordances the editor
itself acts on, and \@nextlyhq/plugin-sdk/admin\ republishes both it and the
contract it belongs to. Renaming an affordance now stops the build instead.
- #1580 `ca8e0cc` Thanks @mobeenabdullah! - Detach a component instance: inline what it was drawing, and stop tracking it.
The instance is replaced, where it stands, by the nodes it rendered — the definition's tree with this instance's overrides, variant and slot content already applied. Afterwards the author owns those nodes and an edit to the definition no longer reaches them, which is the point: detach is how a page keeps what a component gave it without staying bound to it.
The inlined content comes from the renderer's own resolver rather than a second traversal of the definition, so what an author gets is what they were looking at. A planner that re-applied overrides and slots itself would agree with the page until one of the two moved, and the difference would be silent.
A component the author dropped INTO the instance stays a component. That is their content, not the definition's, and detaching its host says nothing about it. It cannot be had from the resolver's depth cap: that bounds nesting inside a DEFINITION, while supplied slot content is composed in the host's own scope where the depth never advances — measured, a nested instance was fully inlined and lost its link. So supplied content never reaches the resolver at all; it is lifted out, stood in for, and put back.
Two halves, two rules. Supplied slot content MOVES: same node ids, same authored DOM ids, because it is the same content in a new parent and the instance holding it goes in the same edit. The definition's contribution is a COPY: fresh node ids, and its authored DOM ids kept except where the page really holds that name.
Nothing the resolver mints for rendering is stored. It scopes a DOM id per
composed node so two instances cannot collide, and resolveComponentInstances
now reports what each scoped id was derived from — without that, detaching wrote
a render-time digest into the database as though an author had typed it, and it
went stale the moment the definition renamed its own anchor.
Provenance goes on the existing origin record's component arm, which the
format already describes as "detached from a component, severing the link
deliberately" — no digest, because detaching is the act of declining further
change.
resolveComponentInstances also stops letting a supplied definition's own
accessors escape. It reads a definition's kind to tell a component from a page
and its nodes to tell a document from anything else, and a field that computes
itself and throws took the caller's error out of a function that promises a
classification and a closed list of reasons — out of the renderer and the
preview as much as out of detaching. Such a definition is now reported
unreadable, which is what that reason already meant: a value that IS supplied
and cannot be read.
The containment reaches the definition's NODES, not only its envelope. A field that computes itself is read later too — by the clone that builds the inlined tree — and it threw out of the resolver and out of detaching alike. It is now contained at the expansion of ONE instance, the unit the resolver's savepoint already covers, so a definition that fails halfway gives back the ids and budget it had begun to claim, exactly as a refusal for the node budget does.
A component whose definition nests an instance of ITSELF detaches. The nested one is refused as a cycle and carries the same component id, so reading the component name refused a detach that had already succeeded.
A gated instance keeps its authored DOM ids. Collisions are decided after the gate is applied, because a condition-gated subtree renders nothing and collides with no one — and a rename made for a conflict that does not exist outlives the gate that excused it.
resolveComponentInstances also refuses a definition written in a format this
build cannot read. It checked only that nodes was an array and kind was
component, so a definition from a future or corrupt writer was inlined and,
for a surface that persists what it inlines, written into a page under rules
this build does not implement. unreadable already meant "an envelope this
build does not understand"; nothing had asked the question.
- #1590 `1cbf54f` Thanks @mobeenabdullah! - Close five more ways a document claim could report one thing while the server
- held another, and stop aborting claims.
Nothing is aborted any more. A claim is not idempotent, so cancelling one makes its outcome unknowable: it may still commit, and an aborted fetch can never hand back the token it was given. The editor would then hold a token the server had replaced with one it could never learn. Only the hold on the one-at-a-time slot expires now; the reply still arrives, and a claim whose slot has moved on is released as the duplicate it is. That converges without the server needing to know anything about client retries.
Intent survives the wait. Whatever waits on the slot is remembered as an intent rather than a flag, and a take-over whose request outlives its slot is re-asked as a take-over. Retried as a plain claim it politely declines to displace anyone, and the colleague keeps the document despite the click.
A repair is queued, not dropped. The signal that a late duplicate displaced the live claim is an acquisition like any other, so it meets the same slot. It is kept separately from a queued claim, because a claim is satisfied by winning the document and a repair is not: what a repair reports is that the token just installed may already be dead.
A repair names the document it is for. Two claims on different documents use different lock keys, so a late reply for one cannot have displaced the other, and waking it would start a claim nobody asked for.
The confirmation timestamp never moves backwards. Renewal replies can arrive
out of order, and an older one landing after a newer one shortened a lease the
newer one had already extended, firing the loss deadline several beats early.
A rejection from a superseded claim is ignored. A retry can win and the
original then fail; reporting that replaced a good claim with unavailable and
left it there, since renewals only move the confirmation forward. It also
requeued a take-over the retry had already satisfied, which later displaced a
colleague with no second click.
A queued take-over survives a repair. A win only spends the click if it actually established possession, and a repair says the token just installed may already be the dead one.
module.require is read through the wrappers around it — parentheses, a
cast, a non-null assertion, satisfies — since each reads the same binding and a
check on the receiver as written is a bypass anyone can reach by accident. And it
claims nothing when the file declares a module of its own, because reporting a
dependency the file never loads is the direction that makes a rule stop being
read.
- #1578 `d8c7a11` Thanks @mobeenabdullah! - Document locking shipped as three unused tables. The engine was complete —
- claim, renew, release and sweep across every dialect, with a heartbeat derived
- from the lease clock and a claim token so a renew proves it is the same claim —
- and nothing could reach it, because no service bound the adapter its functions
- take and no registration constructed one.
documentLockService is registered now. It is advisory and says so: a held
document is reported to whoever asks and no write is refused, so a claim left
behind by a closed laptop cannot strand content. The claim token means
enforcement stays available later without redesigning the shape.
Locks run on the pool rather than a caller's transaction. A claim taken inside a write that later rolls back would vanish with it, and the point of a claim is that it outlives the request that took it.
- #1571 `6a32025` Thanks @mobeenabdullah! -
nextly migrateno longer records an edited collection, single or field group asappliedon the strength of its table existing. Editing an entity leaves its old table in place, so existence proved nothing about the column, status or localization change the registry row was waiting for. A row is now held back while any migration naming it has not been applied, read from the entity header each migration carries and the applied-file ledger. A row no migration names is promoted exactly as before, and migrations whose scope was never recorded are reported by name at the end of the run.
- #1592 `d7a405a` Thanks @mobeenabdullah! - A wholly malformed document is refused before the site's settings are read.
Moving the breakpoint scan ahead of the readability gate put it ahead of the
malformed-root refusal too, so a null, a primitive or an array — a value that
was never going to be validated — still caused the caller's breakpoint settings
to be read, and an adversarial set escaped as a native error.
The coarse root test runs no user code: typeof, a null comparison and
Array.isArray invoke no trap, where asking for a prototype is something a
hostile root can refuse. So the coarse question is settled first and the precise
one stays where it was, after the survey has had its say.
Array.isArray can still throw even though it runs nothing — a revoked proxy
refuses the array brand rather than answering it — so it is wrapped, and a root
that cannot answer reaches the survey's readability verdict instead of being
refused on a question nothing answered.
Both readings now live beside each other as isPlainRecord and
definitelyNotARecord, sharing the clause they agree on and reporting one
issue. The throw-free reading refuses only what the full one would refuse, so
asking it early can never disagree with asking the full one late — a property
asserted over a shared table of root shapes rather than left as a convention.
- #1591 `d130cf2` Thanks @mobeenabdullah! - A saved dashboard whose stored arrangement holds one placement id twice keeps working, instead of the reader losing every card they arranged.
The endpoint has always refused a submission that reuses a placement id, but a row that already held a duplicate — written before that guard, or by anything other than that endpoint — was handed to the admin intact, where the id is the React key and the identity the drag-and-drop sort resolves by; the first match wins there, so a duplicate silently drags the wrong card. Such a row is now resolved when it is split by what the reader may see, so what one reader is handed never depends on a card another reader hid, and the id a repeat is given is derived from the id it repeats rather than minted fresh — the same row answers the same ids on every read, so a client that echoes what it was handed keeps each card's column. Refusing a write and resolving a read now come from one shared analysis rather than two copies of the same rule, and a row that keeps arriving malformed is logged with the id, since nothing on the reader's screen will ever say so.
- #1653 `c3b5e38` Thanks @mobeenabdullah! - Builder: a placement destination now has ONE name.
@nextlyhq/builderre-exports the engine'sPlacementTargetinstead of declaring its ownInsertTarget, and the function that translated between the two spellings is gone.
InsertTarget remains exported from @nextlyhq/builder as a deprecated alias for one release and will be removed after it. Two things to know when migrating:
- The discriminant moved with the name. A target is written { kind: "root" } and { kind: "slot", parentType, slot }; the old at member is no longer accepted, and a value still spelling at is a type error rather than a silent fall-through. DropRegion.at and DropTarget.target hold these values, so a host narrowing them reads .kind.
- @nextlyhq/blocks-engine exports an unrelated type that is also called InsertTarget — where a saved pattern is inserted, OpPosition | "document". That collision is what the deprecation removes: after the alias goes, InsertTarget names only the engine's pattern position.
- #1646 `4807246` Thanks @mobeenabdullah! - The synchronous Direct API accessor is now
requireNextly, exported fromnextly/runtime. It wasgetNextly, which is also whatnextlyexports for a different function: one initialises, takes a required config and returns a promise; the other reads the already-registered singleton, takes nothing and throws when the process has not booted. One name, two functions, opposite tolerance for an uninitialised runtime, and nothing at an import site to say which one arrived.
getNextly({ config }) from nextly is unchanged. It is the one to reach for: it initialises rather than assuming, so it is correct whether or not something else has booted, and it caches, so calling it per request is a lookup after the first. requireNextly() is for code that provably runs after initialisation, and its name now says that it will throw otherwise.
Two pieces of documentation that the shared name had made wrong are corrected with it. The accessor's own example told readers to import it from nextly, where that name resolves to the other function, so the snippet could not compile. The convenience proxy's note said the runtime is initialised "via getNextly()", which is the other one again.
A test asserts that no name is published from two entry points with different arities. It found two more of the same shape, isFieldGroupType and createAdapter, which are recorded so a fourth fails on the day it appears.
- #1652 `9c27d7e` Thanks @mobeenabdullah! - Nothing changes for a site. The page-builder's usage-index machinery — which
- records where a document's references live so the library can say what is still
- in use — now works over any kind of reference rather than named classes alone,
- with class usage as its first configuration. The behaviour, the stored rows and
- the published types are unchanged; the whole existing suite passes untouched,
- which is what the change is checked against.
This is groundwork for the component usage index, so that deleting a component can tell you which pages still embed it.
- #1594 `dad15ed` Thanks @mobeenabdullah! - The inserter can offer a saved pattern, judged by where all of its roots may go.
Its catalog was blocks only by construction, and the type said so: patterns "have no mechanism in this engine and are therefore ABSENT rather than stubbed". The mechanism landed with the composition planners, so the absence became a gap.
InsertEntry is now a discriminated union of a block entry and a pattern entry,
and patternEntriesFrom builds the second from stored rows. A pattern is
multi-root and is inserted as one atomic group, so it may go only where EVERY
one of its roots may go — asked of the same nesting rule a block is asked of,
which is what keeps the palette from offering a placement the insert refuses.
InsertPanel takes the patterns to offer and places a chosen one through planInsertPattern, as one edit — so a whole pattern undoes in a single step rather than one root at a time. The rows to offer are supplied rather than fetched, as the block definitions are: where a pattern lives and how a host loads it is the host's question, and SavedPattern is published beside the panel so a caller can map its query onto it. The drag gesture stays blocks-only, because a drag carries one node to a drop target and a pattern is a forest.
A pattern is offered only if the planner could actually place it: the engine publishes the planner's own pattern-only preflight as patternRefusal, and the catalogue asks it whole rather than keeping a subset of it. The ways a stored row can be unusable are not a short list — the wrong kind, no nodes, an envelope the apply cannot read, a node whose shape it cannot apply, two nodes rendering one DOM id, an internal placement the rules no longer allow — and a pattern is saved once and inserted for as long as it exists, so the rules can move underneath it.
The rules those verdicts ask are now published from the engine as placementVerdict and internalNestingVerdict, and the planner's own refusals derive from them: a palette, a canvas and a planner asking the same question three ways is how one comes to offer a placement another refuses. isPatternDocument is published for the same reason.
- #1575 `62671fd` Thanks @mobeenabdullah! - A dashboard widget can declare
settings— what a reader may change about that card — and a reader's stored choices now reach the query the card asks. Settings are declared as field definitions, so the admin draws them with the renderer it already has and a plugin author needs no new vocabulary.
A setting named limit and typed number sets how many rows its card asks for. Anything a widget does not declare is ignored, and a stored value the declaration no longer recognises falls back to the declared default rather than breaking the card.
WidgetSetting is exported from @nextlyhq/plugin-sdk, and contributes.admin.widgets accepts settings, so a plugin declaring one can name its type. The same card placed twice keeps its own settings and its own data.
A card with settings gains a settings control in the dashboard's edit mode, opening a panel drawn by the field renderer the rest of the admin uses. A widget that draws itself receives its resolved settings and its placement id as props, so a text, checkbox or select setting reaches the component that declared it rather than only the row count reaching the query.
- #1583 `52ad836` Thanks @mobeenabdullah! - A collection created by a migration is queryable on the boot that registered it, in production as well as development.
The runtime schema registry is built before boot migrations run, so an entity a migration registered was visible in the admin and on dashboard cards while every query against it failed until the next restart. Both boot paths now refresh the registry through one shared step, and they refresh it whenever migrations ran rather than only when that particular process registered something — so a replica that waited on the migrate lock behind another one is not left serving a stale view.
- #1574 `4b202ec` Thanks @mobeenabdullah! - A node's provenance record is now checked on both roads into storage.
A document reaches storage two ways — an op through the edit vocabulary, and a
field write through the document validator — and only the op road checked
origin. So an import or a script could persist `{ from: "pattern", id: "",
digest: "" }`, and every later provenance reader would take it at face value: a
staleness check comparing against a pattern with no id answers confidently and
wrongly, and a save-over restoring the DOM ids an insert renamed reads a record
it cannot trust.
Both roads now ask the same published predicate, so a record one admits and the other refuses — one that exists in the database and cannot be edited — is not representable. It is an error in both validation modes: a half-written record is not a value a future build understands, it is a claim about history with a piece missing.
The check reads nothing the record computes for itself, and reflection failures
do not escape. A stored origin may be a caller-supplied object with accessors
or a Proxy whose own reflection traps throw; surveyDocument refuses to invoke
an accessor and already reports such a document unreadable, so the check defers
to that verdict rather than adding a second one about a record nothing can read.
It also reads only the fields the guard actually reaches, rather than every key the record carries, so a document already refused by the byte cap cannot be made to do work proportional to content the bounded survey never traversed.
readBlockOrigin is published beside isBlockOrigin and the guard is derived
from it. A caller that must tell a record it cannot READ from one that is merely
wrong — the validator does — would otherwise name the guard's fields a second
time, and two lists of the same thing drift silently.
- #1587 `c08a619` Thanks @mobeenabdullah! - Version the
fast-uribump, which reaches published output rather than only - this repository's tooling.
@nextlyhq/telemetry bundles every one of its dependencies into its build
(noExternal matches everything), so conf -> ajv -> fast-uri is inlined
into the emitted file rather than resolved by a consumer, and that file is
incorporated into the published nextly and create-nextly-app CLIs. Raising
the override floor from ^3.1.5 to ^3.1.6 therefore changed what those
artifacts contain: 3.1.7 replaces a 3.1.5 carrying four advisories, the highest
being server-side request forgery through malformed IPv6 normalization and host
confusion through skipped IDN canonicalization on scheme-relative references.
The telemetry client validates its own configuration schema and never parses a URI a user supplies, so this closes no reachable hole. It is a patched release of code that genuinely ships, which is why it needs a version rather than only a lockfile entry.
- #1497 `0c1ce76` Thanks @dependabot! - Raise the mysql2 floor to 3.23.1, closing two advisories that reach published
- installs.
@nextlyhq/adapter-mysql declares mysql2 as a runtime dependency, so the range
it publishes is the one a consumer resolves. 3.15.0 accepts an auth-plugin
downgrade to mysql_clear_password, which sends the connection password to the
server in plaintext, and carries an unbounded zlib inflate in the compressed
protocol handler that lets a malicious server answer with a decompression bomb.
The first is patched in 3.22.0 and the second in 3.23.1.
A dependency bump alone would not have reached anyone: the published range only changes for a consumer when the package is released, and nothing here is released without a changeset.
- #1602 `504bcee` Thanks @mobeenabdullah! - Let a departing editor give up a document claim the stored rules have stopped
- permitting.
The per-document update gate ran on every write intent, and releasing a claim routes through the same branch. Those rules read the document, so a holder's own save could flip them mid-claim by changing an owner or a status the rule reads, and from then on the editor's own release was refused. The claim stood until its 150-second lease lapsed, showing colleagues a holder who had already left and pushing them to take over a document nobody was editing.
The intent now names the operation rather than grouping every write together. Claiming and renewing both assert that this editor is editing this document, so both ask the stored rules. Releasing asserts the opposite and stops at the collection's update permission, resting on the claim token that names the one acquisition being given up and fences the delete itself.
- #1597 `02483d8` Thanks @mobeenabdullah! - Saving a descendant of an inserted pattern no longer stores the page-specific id.
An insert records what it renamed on the roots it placed — deliberately, because a descendant did not arrive from the pattern separately and marking every node would make detaching one child read as a second insertion. But the restore read each selected node's own record, so selecting a DESCENDANT of an inserted root and saving that as a pattern found nothing to put back: the suffixed, page-specific id went into the new library entry, where the next insert would suffix it again.
The record is now INHERITED rather than stamped more widely. A node uses its own where it carries one — so a pattern inserted inside a pattern still restores against the one it came from — and otherwise its nearest ancestor's, carried down in the shared node walk rather than a traversal of the planner's own.
The scope is keyed by the node, not by its id: a document reaching a planner is untrusted and may spell one id twice, and an id-keyed scope hands a node under one container the record belonging to a different container of the same name. Where one node OBJECT occurs in two places under records that DISAGREE, nothing can say which occurrence a selection meant, so no record is applied and every id is kept. Occurrences whose records say the same thing are not ambiguous — one insert stamps its map onto every root it placed — and those still restore.
Every record the selection CONTAINS is applied, not only the selected roots': a run inserted from one pattern can hold a second pattern inserted into it later, and reading only the roots stored that nested copy's page-specific ids. A record applies to the node that HOLDS the id, not to the forest: a node moved out of the run that renamed it is no longer governed by that record, and putting the id back would rewrite one the author now owns. Where nothing in the selection renders the id the record can only be about a reference — a link saved without its target — and that still travels. Two records naming one id are settled the same way, by which of them governs the holder.
Inheritance stops at any node CLAIMING provenance of its own, whatever the record says and whether or not it can be read — a detached component's as much as a pattern's, because none of them came from the host. A reference is credited to the node that holds it, asked of every node and every id in one pass, so a nested pattern's own authored reference is not rewritten by the pattern it sits inside and a record naming thousands of departed ids costs no more than one naming a single id. An id two records DISAGREE about is left alone — one restore map has room for one answer, and applying either would rewrite the other scope's reference to a name it never had — while two records that agree about it still put it back, which is the ordinary case when one insert stamps its map onto several referencing roots.
A restore is applied only where EVERY node CARRYING the id — rendering it or referencing it — gives the same answer, so one governed reference cannot rewrite an unrelated author's; and a record storage would not keep — a non-enumerable origin, or a non-enumerable field INSIDE one, all of which JSON, an object spread and structuredClone drop — is not read for its contents, though it still bounds the scope.
The shared node walk also stops crashing on a node whose slots refuse to be read or whose slot record refuses to be enumerated, which is the same tolerance it already had for a slot holding something other than a list. A reference is restored only where the node holding it is governed by the record naming it: a node moved out of the run keeps its reference, and rewriting that would point it somewhere the saved forest never had.
Inheritance stops at any node carrying a pattern record, whether or not that record renamed anything — a collision is the exception, so the ordinary insert writes no rename map at all. And a malformed record on a node nothing selected no longer takes the save down: the walk reaches the whole document now, and provenance is read as the untrusted stored data it is, through the same isBlockOrigin the document validator uses rather than a weaker reading of its own.
A node's own origin descriptor is read ONCE per node — however many times the walk reaches it — and both questions asked of it, whether the node bounds a rename scope and what its record says, come from that one reading. A stored node that answers reflection differently on a second call can therefore no longer have its scope set by one record and its ids rewritten by another, nor be a scope boundary on one occurrence and not on another. A provenance record is likewise read once. patternRenames is published beside the guard that admits a record and derived from the same pass, so what a record must carry to be trusted and what it says once trusted can no longer disagree — and a stored record that is a Proxy no longer has its traps run twice by a reader going back for the contents the guard already walked. Internally, the walk's node classification is now one implementation rather than two character-for-character copies, and it answers in THREE states — a list, not a list, or a value reflection cannot classify at all — because collapsing the third into either of the others is wrong in a different direction each time.
What a CONSUMER sees of that is walkNodes: it no longer hands the callback an entry reflection cannot classify. A revoked Proxy in a forest used to take the walk down from Array.isArray, and containing that alone would have handed the entry over as a node instead, where reading any field off it throws.
The scan that discovers those scopes is BOUNDED, and refuses rather than answering from a partial walk. The shared forest walk revisits a node object reached under two parents — deliberately, since counting it once reports half a real element count — so a document whose branches share objects is exponential in its own depth: nineteen objects each holding the next twice walk as 524,287 entries, and a few dozen would not finish. A save whose selection is somewhere else entirely now refuses such a page for its SIZE, which is the one thing wrong with it and the one thing an author can act on. The cap goes through the published boundedLimit rule rather than a comparison of the planner's own, because a NaN cap fails in the silent direction — NaN + 1 is NaN and every read >= NaN is false, so a caller's bad configuration REMOVES the budget rather than exceeding it, and the walk runs in full before anything rejects it. walkNodes gained onBudgetSpent to make the bound possible — it could already stop on a budget with no way to tell a caller it had, which is a bound that fails in the passing direction — — and WalkOptions is now exported beside walkNodes, because an option that says the budget ran out is useless to a caller who cannot name the type carrying it.
- #1615 `e3b10e0` Thanks @muzzamil-rx! - The Single editor ignored its own version history. The history panel is
- mounted from the system header for collections and singles alike, and it
- publishes the clicked version through shared document context — but only the
- collection entry editor provided that context and answered it. In a Single the
- publication reached the context default, whose setter does nothing, so
- choosing a version fetched the snapshot and marked the row active while the
- live document stayed on screen: a control that visibly did nothing.
The document side of history — the held version, the provider, the banner over the read-only snapshot with restore and return-to-current, the version's own takeover-aware body layout, the loading and failure states, and the holds autosave and language actions observe while a version is on screen — now lives in one host that both editors mount, so the two cannot answer the same panel differently again.
- #1651 `d17bc8f` Thanks @mobeenabdullah! - Stripping HTML from a text value no longer deletes ordinary writing. A
<opens a tag only when what follows it could name one, which is the HTML tokenizer's own rule, soprice < 100and2 < 3 and 5 > 4are stored as written. This runs on everytext,string,textareaandemailfield of every collection, and on media alt text, captions and tags, so an author lost the rest of a sentence on save with nothing to say why.
It is one pass, holding the invariant that a < it kept is never followed by a character that would open a tag. Removing a tag can put its neighbours together into a new one, and rescanning until the text stopped changing holds the same invariant at quadratic cost on a path every create and update reaches.
stripHtmlTags is published from nextly, beside the other security utilities a plugin already reaches for. A plugin storing text a visitor typed has to strip markup the way core does, and the absence of that export is why a second copy grew in @nextlyhq/plugin-form-builder. That copy is gone.
- #1585 `708273e` Thanks @mobeenabdullah! - Document validation is split into the phases it always had, with no change to
- what it reports.
validateDocument was one 257-line function holding four separate jobs: judging
the document's own envelope, deciding what the size survey permits, assembling
the state every node check shares, and walking the forest. The envelope's two
early returns sat in the middle of it, which is why the walk was hard to find at
all — and why nothing in the file could be repaired, since the complexity gate
refuses any edit to a function that far over threshold, however small.
Each phase is now its own function, named for the question it answers:
documentEnvelope (is this a document, and is its outer shape sound),
nodeCheckState (configuration, not traversal), validateNodeForest (the
bounded breadth-first walk) and enqueueChildren (where a slot child sits).
The order of the checks is the order of the issues, and it is load-bearing — callers assert on the first one — so the phases run in exactly the order they did. Every fixture in the validation corpus produces a byte-identical result, survey included.
- #1589 `3520c0d` Thanks @mobeenabdullah! - A document the size survey could not READ is no longer read anyway.
surveyDocument refuses to invoke an accessor — it reports the document
document-unreadable rather than run a getter it was handed — and the node walk
then reached those same fields by ordinary property access, invoking exactly
what the survey had declined to. Ten node fields did it: id, type,
version, props, slots, attributes, cssId, styles, bindings and
visibility, each taking a caller's error out of validate() as a native throw
instead of the issue list it promises.
The document's own formatVersion, kind and nodes did it too, which is why
the check sits ahead of the envelope rather than after it: the envelope is
reached before any node is, and it reads those three to decide whether the value
is a document at all.
A document that merely exceeds a limit is unaffected. Only unreadable
stops the walk. An oversized document was read perfectly well, its nodes are
still checked under the cap, and every per-node issue it produced before is
still produced.
The trade, stated plainly: an unreadable document now reports one verdict rather than several. A duplicate DOM id inside one is no longer named separately. Those documents come from an import or a script rather than from the editor, and the same duplicate on an ordinary document is reported exactly as before.
- #1595 `defb511` Thanks @mobeenabdullah! - A composition plan now carries warnings, and converting a run to a component reports the anchors it will leave behind.
Converting moves a run's nodes into a definition, and composition scopes every definition-authored DOM id per instance — it has to, because two instances of one definition cannot both answer to one id. An author who wrote id="pricing" on a section, with href="#pricing" in a nav elsewhere on the page, was left with a link that resolved to nothing and no indication of why.
CompositionPlan gains a required warnings list. A warning rides alongside a successful plan and never refuses it: nothing here is invalid, no scoping rule makes one id serve many instances, and the author may want the component anyway. The field is always present and empty rather than optional, so a surface has one value to handle instead of two — and it is on the plan rather than in a surface because the plan is the dry run, and the second surface to offer the same action would otherwise have to remember to ask.
A reference from inside the run is not reported: it moves with the run and the relink pass rewrites it.
- #1572 `7a8bef4` Thanks @mobeenabdullah! - An inserted pattern now records the DOM ids it had to rename, and saving that
- copy back stores the ids the source actually uses.
Inserting a pattern renames a DOM id when the destination page already holds that name. The renamed id is a fact about that page, not something an author wrote — so a copy edited and saved back over its own pattern was stored carrying it. That moved the pattern's fingerprint on a save that changed nothing, told every other copy it was stale, and grew the id by another suffix on each insert-save cycle, without bound.
The insert records what it changed, alongside the provenance it already writes,
and the save puts it back — references included, so a aria-describedby or a
#fragment follows the id it names.
Recorded rather than derived, because the original cannot be recovered from the current value: a minted id is the authored one plus a suffix taken from a node id, and content from a script or an import may name anchors that way on purpose.
Nothing to migrate. A record written before this field existed carries no rename map, which says exactly what an empty one says — restore nothing — so an older document behaves as it does today.
A root that only REFERENCES a renamed id records the rename too. One root can
define #pricing while a sibling names it through aria-describedby, an
href="#pricing", or that link's binding fallback — the insert rewrites all
four, so a record covering only the ids a root RENDERS left the referencing root
carrying a page-specific id with no way back. Saved on its own, it went into the
library naming an id that exists on exactly one page and resolves to nothing
anywhere it is inserted next. Restoring now also reaches a node an author gated
after inserting it, which otherwise gave back the reference and not its target.
A component whose forest is larger than the node cap is now refused for its
SIZE. Exposure pointers are resolved against an index built under maxNodes, so
a forest past that bound was indexed only as far as the bound reached and every
pointer beyond it was reported as pointing at a node the document does not
contain — sending an author to repair a sound exposure while the one thing they
could act on went unmentioned.
A component definition can also be duplicated. Its exposed properties and slot regions are pointers INTO its tree, so a copy that re-identifies the nodes without re-aiming them loads, renders, shows its properties in the inspector, and fails its own publish gate with one error per exposure. Exposed ids are kept, because variant presets are keyed by them; DOM ids are kept, because the duplicate is a document of its own.
- #1575 `62671fd` Thanks @mobeenabdullah! - A dashboard card can now be configured from the dashboard itself. Editing the arrangement gives every card that declares settings a Settings button, which opens a panel drawn from the widget's own declaration — so a plugin author gets a settings form without writing one.
Settings belong to the card, not the widget: the same widget placed twice keeps separate settings for each copy. A value left at its declared default is not stored, so a card keeps following the default if the author later changes it.
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-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.62
AlphaReleased 20 packages at 0.0.2-alpha.62 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #1382 `0a3ea83` Thanks @mobeenabdullah! - A block that draws more than one element could only style one of them. Its
- default styles are keyed by block type, so they compile to a single rule on a
- single class — the figure wrapping an image and its caption, or the list
- wrapping its items, share one class and everything not wearing it is
- unreachable.
core/formhad already flattened its own markup to work around - this, at the documented cost that a label sits as far from its own control as
- from the next field.
A block can now name the elements it renders and state styles for each. The names are a closed set the block publishes rather than open selectors, so a block may change what it draws without invalidating styles addressed to it.
- #1376 `067a435` Thanks @mobeenabdullah! - A published page emitted styling hooks that nothing styled, so a correct
- document rendered broken: images ran full width past their column, body text had
- no gutter, and a call to action drew as bare underlined link text.
The mechanism was already here. A block definition's baseStyles compiles to
one rule per block type PRESENT in the document, every default is wrapped in
:where() so it weighs nothing against a site's own CSS, and the token set
merges in three tiers. Six blocks used it; four more do now.
core/image takes max-width: 100% with height: auto. The element carries
width and height attributes from the media record, which reserve its box and
prevent layout shift — and are also a SIZE, so an asset wider than its column
overflowed. Constraining the width alone would leave the attribute height
standing and draw the image squashed, so the pair moves together or not at all.
core/button takes one look rather than variants, because type there is the
HTML attribute rather than a visual kind. Its colours are tokens and its
geometry is literal: a literal colour is wrong in whichever mode it was not
chosen for, while no radius token is guaranteed to exist.
core/list gets its markers back and core/quote its indent. Both are removed
by an ordinary CSS reset — Tailwind's Preflight sets list-style: none on every
list and zeroes margins everywhere, and the scaffold this project ships imports
it — so a bulleted list rendered with no bullets and a quotation was
indistinguishable from a paragraph. The list states list-style-type: revert
rather than a marker, because one rule serves both <ul> and <ol> and naming
a marker would put bullets on ordered lists.
A contained container is finally constrained. The rule behind that class could
not be a block default at all — containment is a PROP, so every container of a
type wears the same block-type class whether it opted in or not, and a default
keyed by type would constrain the ones that declined. It is emitted by the site
stylesheet instead, reading the site's own content.width token through the
same prefix resolution that declared it, and it states no width of its own: a
site whose tokens omit one gets no containment rather than a width from a place
it cannot see.
- #1406 `bc7d846` Thanks @mobeenabdullah! - Add a calendar view of what ships when. The releases page now offers two views of the same set: a list, which answers what launches exist, and a month grid, which answers what is coming and whether anything collides — a question the list can only answer by being read end to end. Each day shows how much is happening and whether any of it has stopped, and selecting a day lists those releases at full width; a month grid goes unreadable if the releases themselves are drawn into it, and the cells are narrow at any window size. Narrow screens get the month as an agenda instead of a squeezed grid, because somebody on a phone is usually asking what happens next rather than what collides.
The times are shown in a zone the reader chooses, named on the page, and remembered between visits. This is not a detail: a release carries an instant and the author's timezone, so which day it lands on has no answer until a zone is named — a launch at eleven at night in New York is the following day in London. Without an explicit choice two colleagues comparing the same page would see one launch on two different days, with nothing on screen to explain why. The zone arithmetic the schedule input already used has moved into one module shared by both, since two implementations of a timezone conversion agree until a daylight boundary and then differ by an hour in a way neither screen can show.
- #1396 `8fa8293` Thanks @mobeenabdullah! - Keep the page-builder canvas centred whenever an author chooses a zoom level. Choosing a scale used to move the page against the left edge of the canvas — not only when zooming out, but at 100% too on any breakpoint narrower than the region, so a mobile preview sat hard left while the same tier was centred before the zoom control was touched. Centring now travels with the width the canvas is given rather than being restated at each branch that sets one, so the box is centred at every scale it can be drawn at, and above 100% it overflows and scrolls from the left edge exactly as before.
- #1393 `36d6ab2` Thanks @mobeenabdullah! - Added the dashboard widget surface: a widget registry, a source registry that names collections rather than tables, a declarative query that is validated before it compiles, and
POST /api/dashboard/queryto run a batch of them for the signed-in caller. Every widget read goes through the ordinary access-controlled path with the requesting user, so a widget returns exactly the rows that caller could have listed itself — including the refusal it would get for filtering or sorting on a field carrying a read rule, which a widget query is given no exemption from.
The sources a widget may name are derived from the collection registry, which is what makes BOTH ways of defining a collection queryable: a collection drawn in the Schema Builder lives only in that registry and has no entry in nextly.config.ts at all. They are read where a query needs them rather than snapshotted at boot, so a collection created while the app is running is queryable without a restart, and one that has been deleted stops being nameable.
The endpoint decides whether the caller may read a source BEFORE it says anything specific about the query. A source the caller may not use answers exactly as one that does not exist does — same for an unsupported op and for a query that fails while running — with the detail in the log, so the endpoint cannot be used to enumerate an install's collections or to read database error text. A malformed request body answers in the same { error: { code, message, requestId } } envelope as every other endpoint.
Changed the shape of a plugin's contributes.admin.widgets entries. component stays REQUIRED, because the dashboard grid renders a widget through its component and through nothing else, so a widget without one would be accepted everywhere and draw an empty cell. The new declarative fields — title, archetype, defaultSize, the size bounds, query and link — are all OPTIONAL additions, so existing { id, component, size } declarations keep compiling unchanged. size is the sizing the current grid reads; defaultSize is published for the archetype-driven grid and is not read yet.
A widget definition is validated more completely at registration: defaultSize must sit inside minSize/maxSize rather than only those two agreeing with each other, defaultHeight must name a real height rather than being enforced by the type alone, a custom widget's component must be more than an empty or whitespace-only string, and overrideWidget(id, def) requires def.id to be the id it replaces. The registry stores an immutable snapshot, so a definition edited after registration no longer bypasses validation, the extendWidget patch allowlist or the overrideWidget path. WidgetHeight, WIDGET_HEIGHTS and the source-contract vocabularies (WidgetSourceField, WidgetSourceFieldType, WidgetSourceKind, WidgetOp) are exported from the root, so every type a published shape names can be named. A collection declared timestamps: false no longer offers createdAt/updatedAt as selectable or sortable fields it does not have.
A source's kind is now derived from its id: the collection:, single: and system: namespaces are reserved for their own kinds, so a source cannot be registered whose id and kind disagree — which previously let a plugin claim collection:posts and make every dashboard query request fail when the collection sources were next rebuilt. Source registration also stores an immutable snapshot, so the field allowlist a query is checked against cannot be edited after it was validated. The fields a collection exposes are read through the shared addressable-fields walk, so a field inside an unnamed presentational group — stored at the collection's top level — is selectable, sortable and filterable, while a field inside a repeater, stored per row, is correctly not offered.
The widget surface is re-exported from @nextlyhq/plugin-sdk, which is the only package a plugin author is asked to import: registerWidget, registerSource, the query and source contracts, and every vocabulary a published shape names. Exported from the nextly root alone, and with no nextly/widgets subpath, the registry could not be reached at all by a plugin following the documented surface. It is @experimental alongside PluginAdminWidget, which is the same feature seen from the contributions side.
A collection with Draft/Published enabled now declares its status column, so a widget can select, sort and filter on it -- the flag reaches the source the same way timestamps does. A collection without one still does not, since the column does not exist there.
A contributed admin widget must carry a usable id and component before it is published. component is required by the type, which reaches a TypeScript caller and nothing else, so a plugin authored in JavaScript could contribute an empty one and the dashboard grid would draw a blank card from it; the id keys that cell, so a blank one collides with every other blank one.
POST /api/dashboard/query checks the request body before it touches the database, so a malformed body or a batch over the cap no longer costs a collection-registry read per attempt. A batch's read decisions are taken through the bounded authorization path rather than started all at once, so 30 queries naming 30 different collections no longer open 30 simultaneous permission reads from a cold cache.
A near or within filter is validated with the same parsers that execute it, so a malformed geo value is refused instead of being accepted and then silently dropped — which had left the query running with no condition at all and returning the whole collection. A widget contributed by a plugin is checked at boot for values that cannot survive being serialized to the browser, because a contributed widget is copied into the /api/admin-meta/workspace payload and one that cannot be encoded failed that request for every admin rather than only its own card. POST /api/dashboard/query answers a body that is not valid JSON with the canonical validation envelope rather than a 500, and the dashboard routes no longer match a URL carrying extra path segments.
- #1402 `0bb454b` Thanks @mobeenabdullah! - The dashboard now draws widgets. The server half — the registry, the query
- contract and
POST /api/dashboard/query— has been merged and unused, with - nothing on the client asking it anything.
Widgets share one anatomy: a header, a body, and an optional footer carrying a freshness line and at most one link. Core draws that frame for declarative archetypes and plugin components alike, so a plugin contributes a body and inherits the loading, error and accessibility behaviour rather than deciding it again. Loading marks the body busy instead of replacing it with a spinner, so a refresh does not discard the number already on screen, and a widget that fails keeps its title — an anonymous error box does not say which card broke.
Every visible widget's query goes out in a single request, and a widget the
current user may not see contributes no query at all. Sizes are named steps on a
twelve-column grid; below the md breakpoint every widget is full width, which
the previous plugin grid got wrong.
Only the metric archetype renders in this release. The rest report themselves
as not yet drawn rather than coming up blank, and a payload that does not match
its archetype says so rather than being silently coerced into a number.
- #1377 `4e13096` Thanks @mobeenabdullah! - Bound the content-releases drain by the deadline its runner supplies rather than a budget of its own, so a pass that starts late fits inside what is left of the tick instead of overrunning it, and measure the remaining pass from the runner's own clock in both built-in drains so an injected clock no longer starves one or overruns the other.
- #1371 `665ec46` Thanks @mobeenabdullah! - A failing scheduled release no longer blocks the healthy ones.
A drain pass always runs its first release group whatever the clock says — otherwise a budget too small for one would leave a backlog stalled forever. With a fixed order that guarantee became its own problem: a release whose write fails holds itself open, is planned first again on the next run, consumes the budget again, and every healthy release behind it waits indefinitely. Nothing crashes and every pass reports success, so the symptom is simply that some releases never go live.
The order now rotates, so every release group reaches the front. One that keeps failing is still retried — that is the contract — but it no longer starves the rest while it does.
- #1325 `5761c64` Thanks @mobeenabdullah! - Give headings and paragraphs a typographic baseline, and let a site's own CSS
- override a block type's defaults.
Under a host's CSS reset an h1 and an h3 differ only in tag name, so a
correct document rendered as undifferentiated text. Block defaults could not fix
it: they are keyed by block TYPE and a heading's level is a PROP, so one
core/heading default gives every level the same size. The compiler now accepts
elementBases, keyed by element, and blocks-react supplies a baseline for
h1–h6 and p.
Both default tiers are anchored to a single page-root class with the rest of the
selector inside :where(). That weighs one class: enough to clear a bare
element reset, and still below a host's own class rule, so a default remains
something a site can override. Previously block defaults carried the doubled
page-root prefix that exists to make an AUTHOR's values outrank host CSS.
The heading scale is sized in em, which is what lets an author's typography
reach a heading at all. These defaults are rules ON the element, while a page
setting or a block's own value arrives by inheritance, and a direct rule beats
an inherited one whatever either weighs — so a page set to 20px left every
heading at its default size. In em the default is a multiple of what was
inherited instead of a replacement for it: the same page now gives an h1
45px, while a document that sets nothing is unchanged and a site's own
.content h1 still wins.
TYPOGRAPHY_DEFAULTS and withTypographyDefaults are exported so a host can
replace the baseline.
- #1361 `db55e6f` Thanks @mobeenabdullah! - A background job now knows how long it has.
Jobs run on a tick — a scheduler calls your site, the runner works through what is due, and the platform ends the request. Work that walks an open-ended set, like publishing every release that has come due, has to stop somewhere sensible and leave the rest for the next tick. Until now nothing told it where that was: the runner held the budget and never mentioned it, so anything wanting to be a good citizen had to be handed the number separately.
Every job handler now receives deadline — the instant its pass intends to stop.
Short jobs can ignore it. A job that walks a large set should stop when it passes
and leave the remainder queued, which is safe because the queue is durable and
the next tick continues where this one left off.
Stopping early must leave the remaining work queued rather than marking it done. Work that was never attempted produces no error, and an absence of failure is easily mistaken for success.
Also fixed: a recurring job whose slug was near the maximum length was accepted when you defined it and then silently refused when the runner tried to queue it, so it never ran. Such a slug is now rejected where you write it, with a message naming the real limit.
- #1381 `faf5e20` Thanks @mobeenabdullah! - Add the document soft lock's server half: a lease on one entry, its heartbeat, its expiry and its sweep.
Two authors opening the same entry previously overwrote each other in silence — there was no lock and no updatedAt precondition, so the second save won and neither author was told. nextly_document_lock now holds one row per document being edited, and acquireDocumentLock, renewDocumentLock, releaseDocumentLock, readDocumentLock and sweepExpiredDocumentLocks take, keep, give up, observe and collect a claim.
Every liveness comparison is a SQL expression the database evaluates itself, on its own clock. Contenders sit on different instances whose clocks disagree, so a claim written from one clock and judged against another is decided by that skew rather than by who holds the lock.
A claim identifies an ACQUISITION rather than a person: each carries a token minted when it was taken, and every heartbeat and release must present it. One author with the document open in two tabs holds two claims under one user id, and a release from the closed tab must not free the claim the other tab is still editing under.
A lease lasts 150 seconds and its holder confirms every 15, both derived from one TTL. Expiry alone releases a claim, so a holder that crashed or went offline does not lock a document indefinitely, and a person may deliberately take over a live one.
The HTTP surface that exposes this is not included, so no behaviour changes for a user yet.
- #1378 `a07bada` Thanks @mobeenabdullah! - A block that declares
supports: { list: true }was rejected by the plugin - authoring types. The style catalog gained a
listgroup, but the authoring - vocabulary plugin authors compile against was never extended to match, so the
- one capability that lets a block opt into list marker styling could not be
- written down.
- #1387 `bfc0785` Thanks @mobeenabdullah! - A block can now say that it needs JavaScript in the browser, and a stored page
- can be asked which of its blocks do. React already decides what ships — a module
- carrying
"use client"is bundled for the browser and one without it is not — - but that is a fact about a MODULE, visible to a bundler. A page is stored as
- JSON naming block types, so nothing reading one could tell an interactive block
- from an inert one without importing the whole library.
The declaration states a reason rather than a flag, because a block becomes heavier for every visitor the moment it opts in and the author is the only one who knows whether that was worth it.
- #1342 `34668e6` Thanks @mobeenabdullah! - A translation restored from the archive can no longer be left describing itself as current.
Deciding whether a translations table carries the column that records when each language was last written used to be a question the database was asked indirectly: a statement was attempted, and any failure at all was read as "the column is not there". A dropped connection, or an account permitted to write the table but not to read the catalogue, therefore answered the same way a genuinely older table does — and on that answer a restore preserves the timestamp already on a language while replacing its content with older archived material, leaving a translation that reports itself as up to date when it is not.
The question is now answered from the table's own column list. Absent is absent; anything that prevents the question being answered is reported rather than being turned into a claim about the schema.
- #1385 `59b6196` Thanks @mobeenabdullah! - Typing an attribution on a quote moved the quotation. A user agent indents a
-
<blockquote>about 40px, and in the attributed shape that margin sat inside - the block's own indent and added to it — so the same quote drew at 24px bare and
- 64px attributed, in any site without a CSS reset. Both now draw at 24px.
An image's caption drew at the body's own size directly beneath the picture, so it read as another paragraph that happened to follow an image rather than as a caption.
A form's fields did not group. One even gap separated a label from the control it names and one question from the next, so nothing read as belonging together — a label sat as far from its own input as from the next field entirely. The gap is now the distance from a label to its control, and the control states the distance to the next field.
- #1323 `566e880` Thanks @mobeenabdullah! - Scheduled content releases are now performed, not just anticipated.
A due release previously changed only what a reader saw. Nothing wrote the change down, so cancelling a release that had already "gone live" silently reverted the content, and nothing outside a live database read ever saw it at all. A release now materialises: each document is published or withdrawn through the ORDINARY content mutation, as the author of the member that scheduled it.
Running it as that person rather than as a trusted system principal is the point. A release is somebody's decision to publish something later, and the write that carries it out is theirs — anything else would let a scheduled publish reach content its author could not have published by hand. A member whose author was never recorded, or whose account has since been deleted or deactivated, is refused rather than run as anybody else, and its release stays scheduled so the next pass retries instead of the work disappearing from both the content and the schedule.
Public pages also expire when a release is due. A cached route previously had two ways to go stale — a tag someone busts, or a fixed number of seconds — and neither fits a scheduled publish: tags do nothing until something runs, so a page cached before the due instant could serve pre-release content indefinitely, and a fixed window is a guess unrelated to when anything changes. The cache lifetime is now derived from the schedule itself, and capped by how stale the schedule this server holds may be.
That cap is a behaviour change worth knowing about: a runtime with a database attached now revalidates public reads on a thirty-second window even where no release has ever been scheduled, where it previously cached them until a tag was busted. The alternative was worse and silent. The schedule is read through a short-lived memo, and a server that has not itself written the schedule cannot see one written by another server for the length of that memo — so a page rendered in that window was being cached with no expiry at all, on the strength of a memo saying nothing was due. It then outlived the release indefinitely, because nothing re-rendered the page to ask again. A page may now never outlive the memo its bound came from.
Three permissions are seeded — reading content releases, creating them, and publishing them. Scheduling is deliberately separate from creating: assembling a release changes nothing a reader can see, while scheduling one is what puts content live later.
The permission resource is named content-releases, not releases. Registering
a resource reserves its name against collections and Singles, and "releases" is
a word real sites use for content — a press-releases collection is among the
most common on a corporate site. Reserving it would have failed an existing
install at boot and quietly cost preset roles their access to a Schema-Builder
collection of that name.
Also fixes the content client handed to a background job, which called the
Direct API through an extracted method reference. That works against the
module-level facade and fails against a booted instance, which reaches its
context through this.
- #1383 `efac6c8` Thanks @mobeenabdullah! - Honour the declared null-ordering control in the Drizzle adapter, which was read nowhere, and make content releases reachable: a
nextly.releases.*Direct API namespace over a service that finally enforces the three release permissions, refusing to schedule a document the caller could not publish themselves.
- #1348 `5d03c89` Thanks @mobeenabdullah! - Every language of an entry can now be taken down at once, through the
- collections service.
Publishing every language has been possible since i18n M7. Withdrawing them had no counterpart at any layer — no admin hook, no route, no service method — so there was no way to take a localized document down as a whole. An ordinary update carrying no locale reaches the DEFAULT language only, leaving every other translation published.
unpublishAllLocales closes it at the service layer. It is NOT yet reachable
from a REST route or from a scheduled release: wiring a release through it was
attempted and reverted in this PR, because the all-languages lifecycle does not
fold a pending working draft or run mutation hooks, and a scheduled publish needs
both. So this ships the missing capability and the honest boundary around it —
the localized takedown gap in Content Releases stays open until that wiring is
designed rather than inherited.
The direction is a parameter rather than a second method. Publishing every
language and withdrawing every language differ in a target status, an access
action, and whether the write can establish first publication; the other 745
lines — the access gate, the row lock, the companion sweep, the version capture,
the event fan-out, the cache flush — are the same operation. publishAllLocales
keeps its signature and behaviour and delegates to the shared path.
first_published_at is untouched by a withdrawal. It records when a document
first became reachable, which taking it down does not change; re-dating or
clearing it would make a later republish report a first publication that had
already happened.
A takedown REFUSES rather than half-performing when a collection's translation table physically lacks its per-language status column — the state left by enabling Draft/Published on a collection that was already localized. Publishing into that state fails loudly and loses nothing; a withdrawal that reported success would leave every translation readable, so this one names the collection, explains the state, and changes nothing.
Per-language writes are unaffected, and a locale-scoped member is never widened into a document-wide one.
- #1360 `8999a12` Thanks @mobeenabdullah! - A scheduled release drain now fits the tick it runs in.
Materialising due releases walked every planned action with no deadline. The job runner cannot bound that — its wall-clock budget is checked before each job is claimed, so it limits how many jobs a pass starts, never how long one already running handler takes. On a serverless platform a tick is killed at a fixed limit, so a site with a large backlog could have its drain cut off partway and then restart from the beginning on the next tick, re-walking what it had already done.
A pass now stops starting new actions once its budget is spent, and reports how many it deferred. It never stops midway through a content mutation: nothing can interrupt one, and abandoning it half-done outside the database would be worse than being late. At least one action always runs, so a budget too small for a single action cannot stall a backlog forever.
Releases whose actions were not reached stay scheduled and are retried on the next pass, exactly like releases with a failed member. Without that they would have been marked published having done only part of their work, losing the members that never ran.
- #1390 `db6c595` Thanks @mobeenabdullah! - Give content releases a REST surface at
/api/releases, where scheduling and cancelling demand the publish authority that assembling a release does not.
- #1397 `15aa6b6` Thanks @mobeenabdullah! - Give the page builder's Settings panel something to show. It was offered on the rail and opened blank for the commonest shape a collection takes — a title, a slug and a builder field — because the panel was filled from the entry form's body rule, which strips title and slug on the grounds that the form header already draws them. A builder covering the whole window suppresses that header, so the two fields most worth reaching from inside the editor were the two being withheld, and a document's own name could not be read there at all. The panel now offers them, grouped as Page above the collection's own fields, and it is withheld entirely when a document genuinely has nothing beside its builder field.
useEntryFieldsPaneltakes the asking field's path and answers with the fields drawn, or null — one value for both the decision to offer a panel and what goes in it, so a surface cannot offer a region it renders nothing into.
- #1347 `408464e` Thanks @mobeenabdullah! - A resizable splitter now has to say what it divides, and the type is what
- enforces it.
The handle between two panels is focusable, so a keyboard user lands on it
whether or not anyone thought about them. react-resizable-panels supplies
everything else about that element — the separator role, the orientation, and
a position between a minimum and a maximum — but it cannot supply the one
thing only the caller knows: what the two panels hold. Every handle in the
admin was unnamed, so landing on one announced a bare number, "74", with
nothing saying what was at 74 or what moving it would do.
The name is now REQUIRED by the component's type rather than recommended in
its documentation. A rule with nothing enforcing it is not a control, and this
one had already been broken at every call site by people who had no reason to
know: the handle looks like a divider, and dividers are not usually things you
name. Either aria-label or aria-labelledby satisfies it, and the two cannot
be combined — a second name is not a stronger label, it is an ambiguity
resolved by precedence rules the author is not thinking about.
The four splitters in the product are named for what sits on each side of them: the page builder's panel-and-canvas and canvas-and-inspector divisions, the API playground's request and response panes, and the translation editor's source and target.
- #1379 `0d6c261` Thanks @mobeenabdullah! - Let an editor take every language of an entry down at once, by wiring the unpublish-all route to the takedown that was already built, tested and reachable by nothing.
- #1408 `a0aea91` Thanks @mobeenabdullah! - Show every kind of title a document can hold.
titleis an ownable system column and a code-first schema may redefine it with any field type, so the value reaching the entry header can be a number or a boolean as well as a string. The header's controlled input accepted only strings and numbers, so a document whose title is a checkbox read as "Untitled" over its saved value. The accepted types are now a set rather than a chain of comparisons, because this list has grown twice and each omission read as a deliberate narrowing rather than a case nobody had listed.
- #1352 `abbc142` Thanks @mobeenabdullah! - A translation now says when its source has moved on since it was written.
The timestamp each language already records is finally read. A language whose source was edited after it was translated is marked as needing review, and stays exactly what it was otherwise -- still translated, and still published if it was published. A translation that has fallen behind is not a demotion; it is a second fact about a language that is still live, and treating it as a state would take a working translation off the screen it belongs on.
Reported only where it can be established. The signal depends on a column older
translation tables do not carry, and whether a given one carries it is now
checked against the database rather than assumed from configuration. A site that
has not run nextly migrate yet sees nothing new instead of an error, and every
language there reports as unknown -- never as up to date, because a translation
the system cannot vouch for must not be described as current.
Singles carry the signal too. Their languages are stamped on every write like any other, so the comparison is as valid there -- but a Single's history is not seeded, so languages written before this shipped have no timestamp and stay unknown rather than being described as current.
Nothing here asks a person to keep the signal honest. It is derived from when each language was last written, so re-saving a translation clears it as a consequence of the save, and no flag is left behind for someone to remember to untick.
- #1332 `406a172` Thanks @mobeenabdullah! - Record when each language of a document was last written, so a later change can
- tell a finished translation from one whose source has moved on since.
This release is the groundwork only: a language's timestamp moves when that language's CONTENT is written, for every kind of localized content, and nothing surfaces it yet. Publishing or unpublishing a language changes no words, so it leaves the timestamp alone -- otherwise a lifecycle change on the source language would report every translation as needing review on an edit nobody made.
Collections that already exist are seeded from their version history, so their languages carry a timestamp from the moment this lands. Singles are not seeded, even though they keep history that would allow it: nothing reads the signal for a Single today, and seeding one would commit every future reader to whatever this release happened to write. Their languages are stamped from their next save onward, like any language whose history is unknown.
A database created before this keeps no history of when each language was written, so the value is seeded from version history where that exists and left unknown where it does not. Unknown is never treated as up to date: a language the system cannot vouch for is left alone rather than described as current.
- #1407 `ffc2a04` Thanks @mobeenabdullah! - A dashboard widget declared through BOTH channels is now MERGED, with the registered definition authoritative over every field it can state. The registry is the single place that knows which widgets exist in a running app, and
overrideWidgetandextendWidgetexist so a later plugin can correct an earlier widget; preferring the contribution discarded every one of those corrections without saying so. A tightenedrequiredPermissionis the case that matters: a widget an operator believed they had restricted was still drawn, and its query still entered the batch, for a user the running configuration said may not see it.
Merged rather than substituted, because a registered definition cannot carry a component on any archetype but custom — so replacing the contribution with it would discard the only thing on either side able to draw a list, table, text or actions card, and a widget that had been rendering its plugin body would render "the list widget archetype is not rendered yet" instead. The contribution supplies the component and the trimmings the registration left out; the registry supplies the permission, the query, the archetype, the title and its declared size. The card keeps the POSITION the contribution gave it, so it does not jump across the grid, but it takes the registry's defaultSize, so its width can change to what the authoritative definition asks for.
Relatedly, a widget that names an archetype this release does not draw now falls back to a component it shipped, rather than showing an error where a working card was available. The fallback is asked of the archetype table itself, so it stops applying on its own the day core learns to draw that archetype. And a duplicate id inside the registry payload is now resolved to its first entry on both read paths, so deduplication and the permission gate cannot disagree about which of the two the payload meant.
A widget result is validated against its own op before it is read. { "ok": true, "result": { "op": "count" } } previously passed an is-it-an-object check and was then read as a count, and the missing total took the whole grid down with it — replacing every card with an error page, which is the exact blast radius the per-slot shape exists to prevent. A result carrying the wrong op for its widget is still passed through, because the archetype refuses it by name and that sentence is more useful than a generic one.
A custom widget that declares a query is now told when a refetch is in flight. The grid keeps such a card's body through a window-focus refetch, so without this the card reported aria-busy="false" while it was reading, and the plugin's own component could not tell a refetch from an idle card — the slot holds the previous answer in both cases. The card's freshness line is shown for these widgets too, since they took part in the batch that produced it — but withheld when their slot was a refusal, because the batch's timestamp is true of the request and not of a card whose body is drawn from a failure.
A card's freshness line keeps advancing while the dashboard sits open. It was computed once at render and the dashboard takes no further renders on its own, so a card fetched hours earlier went on reading "Updated just now". It is now a <time> element carrying the exact instant, with the relative label refreshed on a cadence matched to its own age.
- #1330 `62763e8` Thanks @mobeenabdullah! - Field-level write rules now apply to an anonymous write, not only an
- authenticated one.
A collection may legitimately allow anonymous creates — a contact form, a public
submission. On one, a field declaring access: { create: () => false } was
enforced against every signed-in writer and skipped entirely for an
unauthenticated one, because the write guard treated "no user" as a trusted
system context. An unauthenticated writer could therefore set a field that every
authenticated user was forbidden from setting.
The guard now gates on overrideAccess alone, which is what the matching READ
guard has always done. An internal writer that needs to set a protected field
still says so explicitly with overrideAccess: true; that bypass is unchanged.
An anonymous writer resolves to no permissions and no roles, so a rule asking
for a grant refuses it.
- #1291 `f8048b0` Thanks @faisal-rx! - Share the entry and Single editors' takeover-layout and autosave-recovery logic.
The form body under a layout: "takeover" field, and the restoring of an autosave recovery point, are each computed once and asked by both editors instead of being derived separately in each — so the two editors cannot answer them differently as either changes.
- #1400 `592f074` Thanks @mobeenabdullah! - The dashboard widget contract promised that an accepted query is one the
- executor will run, and three ways of accepting a query that could not run, or
- that ran differently from what it said, are closed.
A geographic count was accepted and then refused at execution: geo predicates
are evaluated over rows a count never fetches, so validation now refuses the
combination rather than letting it fail in its batch slot. An explicitly empty
select read as "no fields" and produced a full document, because a selection
is applied only when it has keys; it is now refused, the way an empty where
combinator already was. And a plugin setup transformer could contribute an
admin widget carrying a value JSON cannot encode: the resolver validated only
the list it was handed, so the widget reached /api/admin-meta/workspace and
failed that request for every admin.
Rebuilding the collection sources is also all-or-nothing now. Two fields that flatten to one name -- which an unnamed layout group can produce without its author writing a duplicate -- used to abort the rebuild after the previous sources had already been deleted, leaving widgets that had worked a moment earlier answering "unavailable source". Duplicates are resolved to the first declaration, and a rebuild that fails anywhere leaves the previous set standing.
The admin's PluginWidgetMeta is derived from the server's declaration through
nextly/config rather than restated, so the two can no longer describe the same
payload differently.
- #1392 `27d9b12` Thanks @mobeenabdullah! - Give content releases a home in the admin. A Releases entry in the sidebar leads to a list of what is going live and when, and each release opens onto its own page showing the documents it contains, so an editor can see exactly what ships before committing to a moment. Releases can be created, scheduled against a named timezone, and cancelled from there, and every action is offered only to a caller who holds the authority the server checks. The instant is shown in the timezone its author chose, rendered through the admin's configured date and time format.
- #1399 `d0ae5d1` Thanks @mobeenabdullah! - An email template preview now shows exactly what will be sent.
Previewing a template and sending it were composed separately, and had drifted:
the preview left out the hidden preheader line, rendered a layout's own
{{year}} and {{appName}} as blanks — so a layout footer previewed empty —
escaped a subject that is delivered as plain text, and never showed the
plain-text part of the message at all. Both now go through one composition, so
they cannot disagree.
Previews also work before a template is saved. A new
POST /api/email-templates/preview renders template fields directly, which the
existing per-template preview route cannot do: it reads the stored row, so it
shows nothing of what is being typed and has no row to read at all while a
template is being created.
- #1389 `330e917` Thanks @mobeenabdullah! - Honour a Nextly instance's own access configuration in
nextly.releases.*, so an instance built withoverrideAccess: falsehas its release operations checked instead of silently trusted.
- #1366 `b54a77a` Thanks @mobeenabdullah! - You can now ask for work to happen later.
await nextly.jobs.queue({
task: "email:welcome",
input: { userId: user.id },
runAt: tomorrowAt9am,
runAs: user.id,
});Queueing returns as soon as the job is recorded, not when the work is done — so a slow task no longer has to happen inside the request that asked for it. The job is a row in your database, so it survives a restart and runs when a trigger next drains the queue.
runAt says when the job may START. Omit it and the job runs at the next drain.
runAs names whose authority the job carries. Omit it and the job acts as
nobody — which is not the same as acting as the system: a job with no identity
gets a content client with no privileges rather than a privileged one. Never take
this value from a request body; choosing whose authority to spend is the caller's
decision to make deliberately.
dedupeKey suppresses a duplicate while an equal key is still outstanding, and
the key is released once the job finishes. That makes it "one export per document
at a time" rather than "one export ever", so recurring work keeps working.
input is typed from the task name once you declare your job types:
declare module "nextly" {
export interface GeneratedTypes {
jobs: { "email:welcome": { userId: string } };
}
}This is the same interface that already types your collection and single slugs, so there is one place to declare what your project contains. Without it, task names are ordinary strings and input is unchecked — nothing breaks, you simply get no inference.
Plugins can declare and queue job types too, via @nextlyhq/plugin-sdk. Marked
experimental there until a first-party plugin ships one.
- #1358 `c3e6028` Thanks @mobeenabdullah! - Stop asking the database a question whose answer is thrown away.
A site whose translations tables have not been migrated yet was paying an extra catalogue lookup on every content list and every single-document read, to establish something the code immediately discarded: whether a translations table records when each language was written, for entities whose translations table is not there at all.
Nothing about what anyone sees changes. The lookup is now made only where its answer is used.
- #1401 `86f6a40` Thanks @mobeenabdullah! - Stop a group of releases as one indivisible step, and refuse to schedule a release that cannot run. Releases that share a document are only ever discharged together, and stopping them one write at a time left the group split whenever a process died partway — the survivor then became the winner on every shared document and could take the opposite lifecycle action to the one the whole group would have produced. Ordering the writes was not enough and could not be made enough: two releases scheduled for the same instant are separated per document by when each member was added, so each can win a different document of the same group, and no ordering of releases preserves a winner chosen per member. The group now moves inside one transaction, so a partial transition cannot be represented at all. Scheduling also now refuses a release with a member nothing can run — a deleted author, no recorded author, a member naming one language — from every state rather than only from a release already marked as stopped, because a colleague leaving does not wait for a background pass and the person scheduling cannot see whether one has run.
- #1335 `8a3a64e` Thanks @mobeenabdullah! - The canvas says how large it is drawing, and lets you choose.
The editor has always scaled the page to fit whatever room the panels left, so opening a panel shrank it — from 89% to 59.5% — with nothing on screen naming either number and no way to set one. An author judging type or spacing was doing it at a size they had not chosen and could not read.
The percentage is now always shown, including while fitting, because that is the state the editor spends most of its time in. Choosing a size instead makes it stay put: the page stops resizing when panels open, and the canvas scrolls rather than shrinking. Fit is still the default, sits in the same menu as the sizes, and is how you get back.
Magnification is new. The old scale could only ever shrink, so there was no way to look closely at anything.
The choice is remembered per browser, like the other editor preferences, and a stored value that is not a usable size is ignored rather than painting the canvas somewhere the control cannot be reached.
- #1369 `d283f53` Thanks @mobeenabdullah! - Choose which interaction state you are styling.
The Style tab now opens with a None / Hover / Focused / Pressed control. Values you edit go to the chosen state, and the canvas draws the selected block in it, so what you are editing and what you are looking at are the same thing.
Each state says whether it already holds styles of its own. Reading the values cannot tell you that: styles inherit, so a state you have never touched shows the base values and looks set. The marker is in the accessible name as well as on screen.
Leaving the Style tab returns the canvas to the normal appearance, so a state switched on cannot be left behind on a tab that no longer shows the control.
- #1380 `a5efcd7` Thanks @mobeenabdullah! - Classify a missing column by the driver's error code rather than by the wording of its message.
Three call sites answered "did this statement name a column the table does not have" independently, and the three disagreed. Two matched English message text; between them they covered disjoint MySQL errors, so each was blind to the case the other handled — a staleness read recognised Unknown column (1054) but not Key column ... doesn't exist (1072), and a degraded index push recognised 1072 but not 1054.
Matching wording is unsound on MySQL regardless of coverage: lc_messages selects among roughly twenty translations, has session scope as well as global, and can be changed at runtime. A server answering in any other language defeated the match silently, and in the dangerous direction — the predicate returned false, the caller concluded the column was present, and the tolerance it exists to provide was skipped.
The single implementation reads the driver code first, per dialect, walking the cause chain where drivers actually put it. Wording is still consulted for a level whose code does not classify: SQLite exposes no code for this, and a wrapper may drop one. Because the code is read first, the wording no longer has to survive translation.
Two narrower views derive from it rather than reimplementing it: whether a specific named column is the missing one, and which of the two forms the error reports, since only MySQL separates an index's missing column from a statement's.
- #1327 `b8d460d` Thanks @mobeenabdullah! - Make the form schema generator read the same per-type rule set the field
- editor reads, so a validation rule cannot be offered by one and ignored by
- the other.
That now includes the custom error message, which the generator applied whatever the rule set said, and the pattern override on a phone field: a stored pattern used to stand the phone's own format check down even where the rule set did not enforce patterns, leaving the field accepting any text at all.
- #1338 `60e1c83` Thanks @mobeenabdullah! - Detect a missing column through the driver error the ORM wraps, so a translation
- table created before the staleness timestamp existed reports "unknown" instead
- of failing the read.
- #1359 `1867585` Thanks @mobeenabdullah! - Find every translation that has fallen behind, in one place.
The translation worklist gains a "Needs review" tab: the languages whose source was edited after they were written, across every collection, newest first. They are still translated and still published -- this is a second fact about a live translation, not a demotion -- so the tab is named for what to do about them rather than for what was measured.
It also says what it could not check. A collection whose translations table does
not yet record when each language was written cannot answer this question, and a
collection that quietly contributes nothing to a list is indistinguishable from
one with nothing to report. Those are now named on screen, with the
thing that fixes it -- nextly migrate on a deployed site, or a restart (or
nextly db:sync) in development, because a development database kept in step by
the sync and reload loop has no migration file that adds the column. That is kept
separate from the collections a single request could not cover, because reloading
helps there and would only loop here.
- #1374 `b1f2b7b` Thanks @mobeenabdullah! - Stop the colour picker saving a colour you did not finish typing.
Typing a hex colour used to save on every keystroke. Because a prefix of a
valid colour is itself a valid colour — #123456 passes through #123 and
#1234 — stopping partway left the last of those saved. Typing #123456 and
pausing at #12345 stored #11223344, a colour nobody typed, replacing what
was there.
A typed colour is now saved when you finish it: press Enter, or leave the field. Dragging on the surface and the sliders is unchanged and still updates as you move. Dismissing the picker mid-word discards the unfinished text and leaves your stored colour alone.
- #1403 `a430cd6` Thanks @mobeenabdullah! - Fix scheduling a release, which failed for every request the admin sent. The check that refuses an impossible date read the UTC offset from a fixed position in the string, and the offset does not sit at a fixed position: seconds and milliseconds are both optional in the accepted format.
Date.prototype.toISOString()always writes milliseconds, so every schedule request from the product carried a shape that made the check compute an unusable date and fail with an internal error. Two quieter faults came from the same line — an instant written with a real UTC offset had that offset read as zero, so a moment shortly after midnight was judged against the previous day and refused as a date that does not exist, and the impossible-date check it performs was silently doing nothing wherever milliseconds were present. The offset is now read from the end of the string, and the accepted shapes are covered by tests written from what a client actually sends rather than from what is convenient to write by hand.
Put the "Add to release" control with the document's other actions, beside Save and Publish. It sat in a bar of its own spanning the full width above the editor, which placed it underneath the sticky side panel on a document that has one: the panel took the clicks and the hover meant for the button, so it could be seen and not used. Both editors are affected and both now render it through the same slot the form already offers, so the control is placed by the same layout that places everything else it belongs with.
- #1362 `15e5315` Thanks @mobeenabdullah! - Preview an interaction state on every rendering of the selected block.
A block inside core/collection-loop is drawn once per entry, so one selected
node is many elements. The forced state reached only the first of them, which
outlined every row while showing the hover appearance on one.
A block whose render returns a promise now keeps its selection outline and its previewed state. React commits the Suspense fallback first and the resolved element later, and that second commit changes nothing the canvas was watching — so selecting an image or a collection loop left it unmarked until an unrelated edit happened to redraw it.
A page rendered with the preview turned off no longer inherits a stored site
sheet that had it on. The route's answer now wins in both directions, so a
page's own rules and the shared class rules cannot disagree about what :hover
selects.
- #1375 `d6d7c57` Thanks @mobeenabdullah! - Background jobs can be granted in the admin again.
manage-background-jobs is seeded and enforced, but the admin's permission
screens did not list the resource — so the row never appeared, and there was no
way to give anyone that permission without editing the database. It now appears
in the permissions page, the role matrix and the capability builder, alongside
webhooks and API keys.
- #1384 `07c615d` Thanks @mobeenabdullah! - The admin dashboard now shows only what the signed-in caller is permitted to
- read. Previously a caller holding NO read permissions was treated as having no
- filter at all, so the least-privileged account saw every collection; the
- activity feed applied no permission filter of any kind; and recent entries were
- read with hand-built SQL that bypassed access control entirely.
Operators should expect restricted users to report an EMPTIER dashboard than before, and that is the fix rather than a regression. A user without read permission on a collection no longer sees its entry count, its draft/published breakdown, its recently-edited entries, or its activity-feed rows — including the entry titles, author names and author emails those rows carry. The "changes in the last 24 hours" figure is now counted over the same permitted collections instead of over every collection.
API keys are judged on their OWN stamped grant rather than on the roles of whoever minted them. A deliberately narrowed key issued by a super-admin previously inherited that super-admin's reach on the dashboard endpoints; it now sees only the collections it was actually granted.
What a caller may read is now decided by the ACCESS LAYER rather than inferred
from the permission table's rows. A collection whose access.read rule refuses
the caller is excluded even when a permission row would have admitted it — the
dashboard now agrees with what GET /api/collections/{slug} answers. A
collection authorized entirely in code, with no permission row at all, is
included for the same reason. Per-collection entry counts are read with access
enforced, so a collection with an owner-only or custom read rule now reports the
number of rows the caller may actually see rather than every row in the table.
Two behaviour changes are worth planning for:
- A super-admin's activity feed is now bounded by the collections and settings
resources that currently exist. Activity rows naming a collection that has
since been removed from the config are no longer listed, and no longer
counted in the 24-hour figure.
- A recently-edited entry whose title field holds a structured value (a json,
group, repeater, component or chips field named by
admin.useAsTitle) is now labelled with its id instead of rendering as
[object Object]. An empty, boolean or date-valued title field falls through
to the next candidate field and then to the id, where before it rendered as an
empty or nonsensical heading. That fallback chain now actually runs: the
recent-entries read selects the fallback fields (title, name) alongside
the collection's configured title field, where before they were silently
dropped by the projection and the chain could never do anything but return
the id.
- The draft/published breakdown on /stats is now read through the same
access-enforced count as the per-collection totals beside it, instead of a
raw query over the whole table. A collection with an owner-only or custom
stored read rule previously reported every author's draft/published split to
every reader who could open it at all, and that number disagreed with
content.totalEntries sitting next to it in the same response; the two now
always agree (totalEntries === status.published + status.draft). This also
fixes the breakdown never actually running for a collection with the
Draft/Published lifecycle enabled: it identified such a collection by
scanning its fields for one literally named status, but a field with that
name is REJECTED by config validation while the lifecycle is on, so every
lifecycle collection was silently treated as having no status at all.
One failure mode is deliberately visible as an empty dashboard: if the permission lookup itself fails transiently, the dashboard answers HTTP 200 with nothing in it rather than falling back to showing everything. An empty dashboard that should not be empty is worth investigating in the server logs.
For anyone calling the Direct API: nextly.count() accepts a status option
("published" | "draft" | "all"), matching nextly.find(). Its absence is
unchanged — an access-enforced count still defaults to published-only on a
collection with the draft/publish lifecycle — but a caller that wants the same
rows a find({ status: "all" }) would return can now ask for them, and the two
totals agree.
- #1367 `b93f913` Thanks @mobeenabdullah! - Mount the classes manager so an author can reach it: the panel reports that usage was not read rather than reporting an empty index, withholds a delete nothing can carry out, and shows a rename the host refused instead of clearing as though it landed.
- #1412 `519c0ed` Thanks @mobeenabdullah! - Give a document one leading action instead of several competing ones. A published entry with unpublished edits drew Save, Publish and Unpublish side by side at equal weight, so an author read three buttons to find the one they wanted, and Unpublish — rare, public, and undone only by publishing again — sat one slip from Publish. There is now a single primary action derived from the document's state, the quieter save beside it, and everything else in a menu split into routine and destructive groups. "Publish" on a document that is already live now reads "Publish changes", which says what it will do. What an author may do is decided in a module with no React in it, so every combination of status, drafts, permissions and history is testable without rendering a header; where each action is drawn is decided from that description rather than by where its markup happened to sit. The collections menu drops "Show JSON" in favour of "View API response", which is what Singles already offered.
- #1356 `5c78e6f` Thanks @mobeenabdullah! - Show the right blocks when previewing an interaction state.
Focus no longer lights up a selected block's ancestors. :hover and :active
match an element and every ancestor of it, but :focus-visible does not — that
is :focus-within, which the builder does not emit. Previewing focus therefore
showed an enclosing block's focus styles for an appearance no visitor sees.
Page-level state styles now apply in the preview at all: the marker is put on the rendered page root, which is what those rules select, rather than on the canvas wrapper around it.
Each interaction state's meaning is defined once and both the published and preview selectors are derived from it, so the two cannot drift into matching different pseudo-classes.
- #1334 `5ea2963` Thanks @mobeenabdullah! - A block can now be dragged from the insert panel onto the canvas.
Dragging a block from the insert panel onto the canvas shares everything with dragging a block already on it — where a drop may land, when the target is allowed to change, the autoscroll, the indicator and Escape. The two differ at exactly one call, made when the pointer is released: a move rewrites a node's position, an insert builds the node the palette described and adds it there.
The node is built at the release rather than at the start of the gesture, so the document is untouched while the author is still choosing, and the whole drag leaves a single entry on the undo stack.
Clicking a row still inserts, exactly as before. The drag only ever adds a second way to do it.
- #1339 `3ddb334` Thanks @mobeenabdullah! - Keep one drag in flight at a time on the block canvas.
A second finger pressing a palette row, or a block, while a drag was already running replaced that drag with no ending of its own. The first drag's release then went unheard and its click was never suppressed, so the row it started from inserted a block the author never dropped — while the second drag carried on. A stray touch now leaves the drag in progress alone.
- #1370 `d70c83b` Thanks @mobeenabdullah! - One scheduled endpoint now drains everything.
Webhook delivery runs on the shared job runner. If you schedule /api/jobs/run,
your webhooks are delivered by it — you no longer need a separate cron entry per
subsystem, and anything added later that needs a regular tick is covered by the
schedule you already have.
Nothing you have set up stops working. /api/webhooks/drain is unchanged and
still does exactly what it did; a deployment scheduling it can carry on. If you
schedule both, they simply share the work — each delivery is claimed under a
lease, so two passes do not both pick up the same one, and whichever arrives
second finds nothing left to claim.
That is not a promise of exactly-once delivery, and never was. Webhook delivery
is at-least-once: the request goes out before the row recording it is finalized,
so a process killed in between leaves that delivery eligible for another attempt.
Every request carries a stable webhook-id, and receivers should continue to use
it to ignore a repeat.
Both triggers now read one set of limits for how much a single tick may do, so they cannot drift into behaving differently depending on which one fired.
A drain pass that ends with failed deliveries is now reported. The job itself completes — a failed delivery is retried on its own schedule and is not a failed pass — so previously a receiver that had quietly stopped accepting anything left no trace outside its own delivery rows.
- #1404 `9717c64` Thanks @mobeenabdullah! - Editors that take over the admin window now share one shell.
Four surfaces already built the same arrangement by hand — hiding the admin's navigation, a bar across the top, and a draggable split down the middle — and each built it slightly differently. The shell names that arrangement once, with its regions declared, so an editor says which parts it has instead of arranging them itself. Nothing changes on screen yet; the email template editor is its first user in a following change.
- #1405 `8f979fe` Thanks @mobeenabdullah! - Make the document title one value rather than two copies of it. The entry header kept its own copy of the title, so a rename made anywhere else never reached it — and a takeover field can now do exactly that: renaming a page in the builder's settings panel left the header behind the editor showing the old name, and the next keystroke there saved the old name back over the rename. The header's title moves into
EntryTitleInput, which reads the form rather than holding a copy, and is a control the header's other surfaces can reuse. Separately, the settings panel no longer counts a group whose every child is conditioned away: those children render nothing, so counting them offered the panel with a heading over an empty group. Conditions on nested fields are now read at the qualified path the renderer resolves them against.
- #1373 `85ab528` Thanks @mobeenabdullah! - Open the Style tab when the editor restores a state you were editing.
A host that reopens the builder on a block you were styling in its hover state now lands on the Style tab, with the state control in view. Previously the canvas drew the block mid-hover while the only control that explains or clears that state sat behind a tab nothing had opened.
An ordinary mount still opens on Content.
- #1372 `b8659c2` Thanks @mobeenabdullah! - Let a host state how many class rows the manager mounts at once, so the bound is a stated page size rather than a fixed one. The size takes effect when it changes, and a value that cannot bound a list falls back to the default rather than stranding classes out of reach.
- #1353 `46976be` Thanks @mobeenabdullah! - Read a
font-familyvalue the way CSS does, in four places it did not. - - A
var()call is dynamic only when it names a custom property. CSS requires - the first argument to begin with
--, sovar(foo)computes to an invalid -
font-familyand the browser drops the declaration rather than falling - through to the next family. Treating every
var(as dynamic gave a dropped - declaration an all-clear.
- - Quoted family content keeps its spacing verbatim.
" Brand "names a family - whose name carries those spaces and is a different family from
Brand; - matching it against a face called
Brandclaimed a file renders that does - not. Whitespace outside the quotes is still separation and still trims.
- - A bare
defaultis invalid rather than a whole-value keyword. Unlike -
inherit,initial,unset,revertandrevert-layer, it is excluded - from
<family-name>, so the browser drops a declaration reading it bare. - - A comma inside
var(--font, Arial)is not a family separator. Depth is - counted rather than flagged, because
var(--a, var(--b, serif))nests.
emitTokenBlocks now reports the tokens it wrote alongside the CSS and its
issues. It refuses a token on five separate grounds, and a caller asking "which
tokens does this site emit" had to restate all five — a second statement that
agrees today and drifts the first time one changes. The fonts panel reports on
that list, so a token the compiler refuses is no longer described as a typeface
the site renders.
The fonts panel draws a subset face with glyphs that face covers. A face limited
to a non-Latin unicodeRange renders none of the Latin specimen, so the row
demonstrated another subset or a fallback rather than the file it names.
- #1410 `15eedaa` Thanks @mobeenabdullah! - Put the release lists on the same table every other list in the admin uses, bound how large a release may get, and say what would stop a release before it is scheduled rather than after. The list of releases and the documents inside one were the last surfaces still built by hand from cards, which cost them column alignment, sorting and the shared empty and loading states that entries, media, api keys and webhooks all share. A release can now hold at most a thousand documents — a bound rather than a policy, since nothing in the model stopped one growing until it could neither be displayed nor settled in a single pass. And the schedule dialog now asks what stands in the way while somebody is choosing the moment, naming the documents whose author has gone or which name a single language, because the server's refusal can say that a release cannot run but not which documents to fix.
- #1343 `cd184c0` Thanks @mobeenabdullah! - Add the fonts panel, which reports which typefaces a site will actually render.
compileSiteSheet emits @font-face blocks and token custom properties
independently, so a fontFamily token naming a family the site loads no face
for is emitted exactly like one that has it. The page then draws in whatever
the browser reaches for next, nothing errors, and nothing is logged — the same
silent substitution the sheet's own tokenPrefix note describes one property
along.
The panel joins the two lists, which is the only way to see it: the tokens studio edits one token and knows nothing about faces, and the inspector knows nothing about either. It authors nothing itself, so creating and renaming typeface tokens stays the studio's single job.
Every family is drawn in itself. A list of typeface names set in the interface's own font asks an author to choose a typeface from its name, and a family the site does not provide renders in the fallback — so the substitution is visible rather than described.
Nothing is called missing or unavailable. A named family with no face may be installed on the reader's device, so the wording says what is true: this site provides no font file for it.
readFamilyList, familyPartKind and splitFamilyList are now published from
@nextlyhq/blocks-engine, and they classify rather than answering yes or no.
CSS reads a family list four ways and a boolean misdescribes two of them: a
stack holding var(--font-geist) is read perfectly and cannot be resolved from
the text, and a lone inherit is valid while naming no family at all. Each item
is classified as a name, a generic keyword, a var() substitution, a CSS-wide
keyword, or invalid; the list's own reading follows from those.
familyToDtcg asks the same reading and applies its own narrower rule to the
answer, so the DTCG export and any surface reporting on a site cannot disagree
about what a browser will read.
splitFamilyList no longer discards empty items — it keeps them, marked
invalid. font-family: Brand, is a parse error the browser drops the whole
declaration for, and reporting it as the single family Brand described a value
the page never rendered.
BuilderShell's openInsertPanelToken is now openPanelRequest, carrying the
panel alongside the count. Opening insert from the canvas appender and opening
tokens from the fonts panel are the same request with a different subject, and
two props would have been two answers to one question.
- #1286 `26dd60a` Thanks @muzzamil-rx! - Pressing Enter to accept an IME suggestion no longer submits a rich-text insert
- dialog.
Choosing a candidate in a Japanese, Chinese or Korean input method ends with Enter, and the dialogs read that as "insert". An author composing a link title or a button label had the dialog close on them mid-word, with whatever the editor had at that moment.
The table, video, button and button-link dialogs also share one shell now, rather than each carrying its own copy of the same open, focus and submit behaviour.
- #1355 `76346bb` Thanks @mobeenabdullah! - Scheduled content releases now actually publish themselves.
The background job runner has had everything it needed to do this except the two things that make it run: a way to be triggered, and a list of the work it knows how to do. Both are here. A release drain is registered as a job type at startup, and a new endpoint runs the queue.
Point your scheduler at /api/jobs/run — Vercel Cron, a system cron, or anything
that can make an HTTP request on an interval — and due releases publish without
anyone being awake. Each pass is bounded, so an invocation finishes well inside a
serverless time limit and the next one picks up where it stopped; the queue is a
table in your database, so nothing is lost in between.
Who may pull that trigger is the same question the webhook drain already
answered, and it now has one answer rather than two. A scheduler authenticates
with a shared secret — NEXTLY_DRAIN_SECRET, or Vercel's own CRON_SECRET —
compared in constant time. A person authenticates normally and needs the new
manage-background-jobs permission, which super-admins receive automatically.
Running the queue by hand grants nothing else: every job runs as the person it was queued for, resolved when it runs, so pressing the trigger makes work that was already scheduled and already authorized happen now. It does not let the person who pressed it do that work themselves.
background-jobs is now a reserved name. If you have a collection or Single
called background-jobs, rename it before upgrading — a content type sharing a
name with a system resource would have its permissions treated as the system's,
and preset roles would quietly lose access to it.
When a release fails to publish — its author was deleted, a write was refused — that member is now reported individually in your logs, with the document and the reason, rather than being counted and forgotten while the release silently waits for a retry that will never succeed.
- #1394 `4c1e006` Thanks @mobeenabdullah! - Tell an editor, on the document itself, when it is going to change on its own. A document in a scheduled release now carries a bar above the editor naming each release, what will happen to it, and the moment in the timezone its author chose — and answering the question an editable scheduled document raises, which is whether changes saved now are included. They are: a release points at its document rather than copying it, and publishing promotes whatever the working draft holds at that instant. The release list can be filtered to the releases holding one document, which is what the banner reads.
- #1395 `9726e90` Thanks @mobeenabdullah! - Stop a release that can never run, and say what to fix. A refused release used to stay scheduled and be replanned every tick forever while reading exactly like a healthy one, so nobody learned anything until the launch did not happen. Only permanent refusals stop it — a member with no author, an author who has been deleted or deactivated, a member naming one language — because the transient ones are documented as evidence of nothing and halting on a momentary database error would lose a launch the next pass would have completed. The release page then names each member standing in the way and what resolves it, worked out from the members on every read rather than recorded when it stopped, so it stops naming a cause somebody has already fixed.
- #1350 `9ab4632` Thanks @mobeenabdullah! - The permissions UI now knows about content releases.
Adding content-releases as a system resource in core left the admin's four
copies of that list behind, so the permission was filed under Collections in the
role editor, mapped as per-collection access by the capability builder, and
rendered under the wrong bucket on the permissions page. Nothing threw — a
miscategorised permission simply shows the wrong thing, which is why a
role-matrix entry that quietly changes what preset roles can reach is worth
fixing as a defect rather than as tidying.
Content releases now sit with the editorial surfaces in the permissions page's display order, next to media, rather than after the delivery and integration entries: it is a tool an editor reaches daily, not infrastructure.
- #1351 `8bcc279` Thanks @mobeenabdullah! - Fit the canvas to the pane it is actually laid out in.
The canvas measured its DOM parent to decide how far to shrink a pinned width.
Since the block context menu arrived, that parent is a wrapper with
display: contents — it generates no box, so it measures zero, and a zero
region made the fit fall back to its identity. An author who pinned Tablet at
1024px was editing at whatever width the pane happened to be, with the control
still showing Tablet selected and no readout to contradict it.
It now measures the nearest ancestor that actually generates a box.
- #1346 `d4a3ecf` Thanks @mobeenabdullah! - Let the canvas show the interaction state the style panel is editing.
A page cannot force a pseudo-class on itself, so an author switching the panel to hover was editing an appearance nothing could show them. A preview sheet now gives each state a class alternative beside its pseudo-class, and the canvas puts that class on the selected block — so hover, focus and active look on the canvas the way they will look to a visitor.
The alternative, measuring which pseudo-classes actually match, was declined:
the pointer is in the inspector whenever anyone is reading the panel, so a
measured :hover is false every time and every hover control would report
unset permanently.
The marker sits inside the :where() that already wrapped each pseudo-class, so
it carries no specificity and a previewed rule weighs exactly what the published
one weighs. Published sheets do not ask for it and are unchanged.
- #1409 `d6b45e4` Thanks @mobeenabdullah! - Internal: the email template editor is now a directory of focused modules
- rather than one 1878-line file.
No behaviour changes and no API changes — the editor renders exactly as before and is imported from the same place. The split gives each region of the editor its own module, which is what the forthcoming rework of that screen builds on.
- #1411 `b4894b8` Thanks @mobeenabdullah! - The email template editor now gets the window.
Editing a template used to leave roughly half the screen to navigation: the settings menu stayed open beside a fixed panel of variables and settings, and the code and the preview shared what was left. On a 1280px screen that was 316px each — too narrow to read a line of the template or to see the email at its real width.
The settings menu now steps aside while you edit, and the two panes you are actually working in take the space, with a handle between them so you can give whichever one you need more room. Everything that addresses the mail — From, Reply-to, Subject and the preheader — sits together at the top instead of being split between the editor and a settings tab, and the variables you insert are beside the cursor rather than a panel away. Settings open over the preview when you want them and leave the code where it was.
- #1413 `9c5c169` Thanks @mobeenabdullah! - Fixes four problems in the email template editor.
Save no longer appears to do nothing. If a declared variable was missing its name and you had closed Settings, saving was refused with no message anywhere, because the only place that message appears is inside Settings. Saving now reopens the panel that holds whatever needs fixing.
On a phone the editor's action bar now wraps instead of pushing Save off the side of the screen, and the variable strip above the code can no longer grow until there is no room left to write in.
And the settings menu, while it steps aside during editing, no longer leaves its links reachable by keyboard — tabbing could previously carry you out of the editor through a menu you could not see.
- #1340 `b566a48` Thanks @mobeenabdullah! - The Insert panel is a grid of tiles, and the block you are pointing at explains
- itself in a strip along the bottom.
It was a list: one block per row, each carrying two lines of description. Every block was legible and only a handful were visible at once, so finding one meant reading rather than recognising, and the panel ran to well over a screen for a library of twenty blocks.
Making it a grid on its own would have meant deleting the descriptions. A tile at the panel's default width is about eighty-five pixels — an icon and a short word — while the descriptions run from ninety-nine to a hundred and eighty-five characters, and they are not padding. Card's description is what says it CLIPS its contents, which is the whole reason to choose it over Box. Accordion's is what says it restricts what can be dropped inside it. A grid that dropped them would look better and answer fewer questions.
So the descriptions moved rather than went. The tile under the pointer, under the keyboard, or under a finger is described in full in a strip at the foot of the panel. It follows FOCUS as well as hover, which is what separates it from a tooltip: a tooltip is reachable only with a mouse, so it is no help on a touch screen and no help at all to anyone arrowing through the panel. It follows a PRESS too, because a touch screen sends no hover before contact — without that, the tap that finally moved the description would be the same tap that inserts.
Arrow keys are unchanged, and that is deliberate rather than an omission. The palette publishes listbox semantics, where a screen reader announces "option 4 of 18" and Down means the next option; moving by a grid ROW instead would make that announcement wrong by two every time. An honest grid keyboard needs a grid accessibility tree — rows, cells and coordinates — and that is not something the panel can add on top of the widget it composes. The grid is a layout, and reading order runs left to right and then down, which is the order the arrow keys already move in.
Each tile is now NAMED by its block and DESCRIBED by its sentence, rather than announcing the two run together. A screen reader previously read a tile as "TextA paragraph of plain text" — the name and the description concatenated with no separator, and whether any separator appeared at all depended on the stylesheet rather than the markup. The two are now stated separately, so the block's name is read first and its description second.
The description reference is also safe for blocks nobody has written yet. A
variation's name is an unrestricted string and a variation is identified as
block#variation, so a variation named "wide card" used to put a SPACE in the
reference — which is a space-separated list of ids, so assistive technology
looked for two ids that did not exist and announced the tile with no
description at all.
The description strip now READS the palette's highlight rather than steering
it, and @nextlyhq/ui publishes useCommandHighlight so it can.
Steering it was wrong in a way that only assistive technology could see. The palette's controlled value sets which tile is MARKED and does not move the internal cursor that the announced option and the scroll position follow — so the tile drawn as current and the option announced as current drifted apart, and after a search removed the highlighted tile the announcement named an element that was no longer in the document at all. A reference that resolves to nothing is worse than none: a screen reader is told there is a current option and then cannot find it.
Reading the palette's own state leaves one owner. It follows a pointer, an arrow key and a filter alike, because those are the palette's business and it was always doing them correctly.
Two smaller repairs to the same panel. A tile's identifier is now allocated once and never reused, so a host replacing the block definitions while the panel is open cannot have an identifier come to mean a different block — which would have described one block and inserted another. And the description strip returns to the top when it changes subject, instead of opening the next description partway down where a previous one had been scrolled.
- #1344 `66bbff0` Thanks @mobeenabdullah! - Report a heading's typographic baseline even when its block renders
- asynchronously.
The style inspector reads which element a node is drawn as by looking at the
canvas. That read was driven by a dependency list, and the DOM moves for reasons
no prop captures: a block whose render returns a promise commits its Suspense
fallback first and its resolved root later, changing neither the canvas element,
nor the selection, nor the document. The read therefore ran only before the
marked element existed, and an async block resolving to a heading reported its
font size as unset for as long as it stayed selected.
The reader observes the canvas subtree now, including the node-id attribute itself — a node's id moving between elements changes the answer without adding or removing any.
- #1368 `0188d15` Thanks @mobeenabdullah! - Fix the translation worklist's review tab, which returned an error instead of a
- list.
Selecting "Needs review" asked for an internal service that is never registered, so the tab failed before it could look at anything. The other four tabs never took that path and were unaffected. It now derives what it needs from the collection it already has.
- #1354 `e5780f8` Thanks @mobeenabdullah! - The Insert panel's description strip keeps checking whether it is hiding text,
- rather than checking once per block.
The strip becomes a focusable, named region when a description is too long to fit, so a keyboard can reach the part a pointer would scroll to. It measured that only when the highlighted block changed — so dragging the panel narrower, raising the browser zoom, or increasing the font size could push a description past the edge with nothing noticing. Its tail was then unreachable without a mouse. Widening the panel had the mirror problem: the description fitted again and the focus stop stayed, so tabbing toward the blocks went through a region that had nothing more to show.
It now watches its own size, which catches every one of those, and re-checks on each render, which catches the case a size watcher cannot see: a block whose description is replaced with a longer one keeps its identity and its box, and only the text inside it grows.
- #1363 `e87bdb5` Thanks @mobeenabdullah! - Read only the
var()calls CSS actually substitutes, match a hosted font family verbatim, choose a specimen the face can draw, publish the projection from a registry record to the draft-split question, and report a class refusal that arrives after the control raising it has gone.
- #1357 `ddd6129` Thanks @mobeenabdullah! - Withdrawing somebody's access now stops their scheduled releases too.
A release runs every action as the person who scheduled it, deliberately, so that scheduling cannot become a way to publish something you were not allowed to publish. The read side did not know that. It projected any due member of any scheduled release without checking whether that person still existed or was still active — so deactivating an employee stopped their scheduled publish from being written while every visitor went on seeing it as published.
It was permanent rather than temporary. A member whose author cannot be resolved fails, a failed member holds its release open, and a release that stays scheduled goes on being projected forever.
The read path now derives from the same answer the write path does: a due member is projected only while its author exists and is active. A member with no recorded author is not projected at all, matching the write path's refusal — there is nobody to act as, so it describes an effect no write could perform.
If the lookup itself fails, nothing is projected rather than everything: a database that cannot answer must not be read as "everyone is still authorised".
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-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.60
AlphaReleased 20 packages at 0.0.2-alpha.60 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #995 `205ac43` Thanks @mobeenabdullah! - The admin and
@nextlyhq/uinow enforce the same design-token rules shipped to plugin authors.
Twelve inline styles became utility classes, so those surfaces follow the theme and the spacing scale rather than fixed values. The page-builder drop indicator was painted a fixed deepskyblue that ignored light and dark mode entirely; it now uses the primary token.
The email preview's palette is named in one place and documented as deliberately literal — mail clients do not resolve CSS custom properties, so a preview built from admin tokens would show authors something recipients never receive.
A design-lint-ok exemption now annotates the construct it precedes rather than a single line, so a multi-line declaration needs its reason recorded once. The reach is bounded and cannot extend past a function.
- #990 `da50ecb` Thanks @mobeenabdullah! - Outline badges now show the border colour they are given. Seven places in the admin ask one for a
- specific colour — a green edge on an enabled plugin, red and blue on submission states — and every
- one was quietly drawn in the default grey instead. The enabled-plugin pill in particular asked for
- a stronger green so its edge stays visible against the background, and did not get it.
- #999 `f9dbc5f` Thanks @mobeenabdullah! - Restoring a version is now offered from the historical-document banner: the entry form passes its restore handler through, so an authorised reader sees the action beside the version they are reading rather than nowhere at all.
- #998 `6bf8cca` Thanks @mobeenabdullah! - The selected block can now be edited. An inspector reads the props a block declares and draws a control for each, so a heading inserted from the palette can be given its real text instead of staying at the example the palette supplied.
- #983 `14dc716` Thanks @mobeenabdullah! - The selected block is now outlined on the canvas, and a keyboard move is announced to assistive technology — naming whether the block was reordered or moved into or out of a container, which a keyboard author cannot see.
- #991 `376429a` Thanks @mobeenabdullah! - A block can now be deleted with Delete or Backspace, and any edit undone with Ctrl+Z or Cmd+Z and redone with Shift+Z or Y. The editor could add and reorder blocks but not remove them, and its undo history had no way to be invoked.
- #1005 `8df7086` Thanks @mobeenabdullah! - The entry editor's language tools are now visible, reachable, and legible.
In a localized collection, the document title was invisible: the language strip shared its header row and squeezed the title input to zero width at every screen size. The title now has its own row, with languages on a row of their own beneath it.
One segmented control shows every language with its state (published, translated, draft, or not translated — carried by shape and text, never colour alone) and switches between them; it replaces the separate dropdown and pill row that both switched languages. A Languages menu in the header offers Copy from and Publish all languages at every screen width — previously those lived only in a side panel that disappears on smaller screens — along with a legend for the language states.
Creating an entry now says which language it will be created in. The "Shared across languages" badge appears only while editing a non-default language, where it matters. If a language fails to load, the editor offers the way back to the default language instead of only an exit.
- #985 `2d11910` Thanks @mobeenabdullah! - The collection-entries list now remembers your column choice the same way every other admin list
- does. Behaviour is unchanged — it already remembered — but it is one mechanism now rather than
- two, so the lists cannot drift apart.
- #984 `811c4bf` Thanks @mobeenabdullah! - A field rendered twice on one page no longer collides with itself. A field's DOM id was its path,
- which is unique within a form and not within a page — so showing a past version beside the live
- editor gave both copies the same ids, and every label in the version panel pointed at the live
- editor's control instead. Those fields lost their accessible name, and clicking one of their labels
- moved focus into the editable document. Ids are now scoped per rendering; the live editor's ids are
- unchanged.
- #996 `42f0c1e` Thanks @mobeenabdullah! - Reading a past version is now read-only all the way through, and can be acted on from where it is
- read. The save controls stand down while a version is on screen — they act on the live document,
- which is not what is being read — and restoring is offered from the banner over the version itself.
Three things that stayed editable are fixed with it. The title and the slug are part of the document, so they lock with the rest of it rather than quietly changing the live entry from a historical page. The set of fields a version shows is now decided by that version's own values, so a document whose layout has changed since is not shown through today's layout. And returning to the live document clears the panel's selection too, so no row stays marked as the version on screen.
- #994 `85d0d97` Thanks @mobeenabdullah! - Version history reads in the document now. Choosing a version from the panel renders it where the
- document is, read-only, with a banner naming which version is on screen and a way back to the live
- one — instead of squeezing a page into a 480px column beside it. The panel keeps the timeline.
The live document is untouched throughout: the historical values are rendered against a form of their own, so nothing typed is disturbed by opening history and nothing historical can reach a save or an autosave. An empty history now leads with a heading rather than a sentence, because when the panel is empty that line is the only thing in it.
- #1001 `055dc7f` Thanks @mobeenabdullah! - Selecting an empty container and inserting now puts the block inside it. Every insert landed as a sibling before, so a container could be added and never filled, and the panel says which of the two placements it will use.
- #993 `5037057` Thanks @mobeenabdullah! - Inserting a block now adds one you can see. Blocks were inserted with their defaults, which are deliberately empty, so a new heading rendered as an empty element with no height and the page looked unchanged.
- #989 `ff2fb60` Thanks @mobeenabdullah! - A JSON field's parse error is announced again. Scoping field ids moved the error node without
- moving the reference to it, so in a version preview the control described an element that did not
- exist — and a dangling description reaches assistive technology as no description at all, which is
- worse than the plain error it replaced.
- #980 `fad081c` Thanks @mobeenabdullah! - Every admin list now remembers which columns you hid. Previously only collection entries did, so
- narrowing a wide table anywhere else lasted until you closed the tab. Each list keeps its own
- choice, so hiding a column on Users does not change what Roles shows you.
- #1002 `b903379` Thanks @mobeenabdullah! -
no-hardcoded-colorsnow catches CSS named colours.
The rule already rejected hex, rgb(), hsl() and oklch(), but a name like deepskyblue passed — which is how a fixed colour that ignores light and dark mode sat on the page-builder's drop indicator until it was found by reading a file flagged for something else.
A colour name is an ordinary word, so it is reported only where its position makes it a colour: the value of a colour-valued style property, or the right-hand side of a CSS declaration. Prose and data are untouched — "the red team", { fruit: "plum" } and a label reading "Tomato" are all fine. black, white and transparent stay exempt, as their hex spellings already were.
The rules also stop applying to test files, matching the repository's CSS guard. A fixture writing color: red is modelling arbitrary user data rather than styling a surface that ships.
- #982 `ea623f2` Thanks @mobeenabdullah! - Plugins can now actually place their sidebar menu items, and the type describing where they go is importable.
The previous release accepted a section declaration on menu items and then ignored it: every item was flattened into one list rendered under Plugins, so a plugin placed under Settings had its pages in one panel and its menu items in another. Items are now attributed through the same chain a plugin's pages use — the item's own declaration, then the plugin's placement, then Plugins.
PluginNavSection, the type the field is declared with, was also missing from both the nextly root and @nextlyhq/plugin-sdk, so a plugin author could not import it.
- #1004 `9b27446` Thanks @mobeenabdullah! - A scaffolded plugin no longer bundles a copy of
@nextlyhq/ui.
The UI kit is supplied by the host admin at run time, but the plugin template declared it only as a devDependency — and tsup externalises peer dependencies while bundling dev ones, so the entire kit was inlined into every published plugin. It is now declared as a peer, matching the first-party plugins, and named in the template's externals.
- #986 `e56dddb` Thanks @mobeenabdullah! - Fix five further defects in
@nextlyhq/eslint-pluginand its guard.
no-static-inline-style reported a computed style key as constant. { [cssProperty]: 8 } styles whichever property the variable holds, so the declaration is runtime-dependent and the rule was rejecting correct code.
no-hardcoded-colors did not detect oklch() or oklab() literals, which matters more than the older spellings because Nextly's tokens are themselves OKLCH.
The design-lint-ok exemption was matched by substring, so a bare marker silenced a rule while recording no reason, and unrelated text containing the marker silenced one by accident. It is now a directive that must carry a reason.
@nextlyhq/plugin-sdk imported the design-token config without applying it, so its own lint never ran these rules.
- #988 `03cd7d8` Thanks @mobeenabdullah! - The line under the selected tab now follows the theme. It was marked so that nothing could
- override it, which made it the one piece of admin colour a retheme could not reach.
- #1003 `03e5182` Thanks @mobeenabdullah! - Reading a past version now waits for the snapshot to arrive before showing it or offering to restore it. A version read that has not returned reports neither progress nor failure, which previously rendered an empty document as though it were the version and enabled restore for a version nobody had seen.
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-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.58
AlphaReleased 19 packages at 0.0.2-alpha.58 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #720 `8a7e734` Thanks @mobeenabdullah! - Take the reference palette's light-mode values for the admin, and record what
- that costs where a reader will find it.
--nx-input, --nx-border-strong and --nx-sidebar-border move to the
reference border weight; --nx-destructive and --nx-destructive-solid move to
the reference red; --nx-sidebar-foreground matches the active nav ink so the
sidebar reads at body-text weight.
Several of those render below their WCAG minimum, deliberately. Each affected
pairing is listed in the new contrast/accepted.ts with the ratio it actually
measures, and the contrast suites hold every entry to three properties: it still
measures what is recorded, it is still below its threshold, and it still names a
token the theme declares. The sharpest is white on the destructive fill at
3.84:1, which is the label of the Delete, Discard and Unpublish confirm buttons.
Because resting and active sidebar ink are now one value in light mode, the active row also carries a font-weight change. A fill at 1.11:1 cannot identify a state on its own, and a weight difference is not a colour, so it is not subject to a contrast ratio at all.
Dark mode is unchanged apart from --nx-success, which moves a step lighter to
clear its minimum on the muted surface with the margin the suite requires.
Checkbox and radio take a new --nx-control-border rather than following the
field border down. A field is identifiable without its edge; an unchecked box is
only the box, so its boundary is held to 3:1 with no acceptance.
- #830 `f53dbd8` Thanks @mobeenabdullah! - Give every admin list one owner for its page state, and one source for its page-size policy.
Twelve list surfaces each held the same two useState calls plus their own handlePageSizeChange wrapper that set the size and then reset the page. They now use the existing usePagination hook, which owns those resets. That removes the drift risk across the copies and, more usefully, removes the wrapper entirely: onPageSizeChange is the hook's setPageSize, which resets the page in the same update, so a query keyed on both refetches once rather than twice.
The page-size options were a literal [10, 25, 50] written out at nine call sites. They are now PAGINATION.TABLE_PAGE_SIZE_OPTIONS, beside the existing page-size constants, so the policy can change in one place. The two lists that deliberately differ keep their own: the media grid offers 12/24/48/96 because it lays out thumbnails, and the delivery log offers 20/50/100 because it is read in long scans. usePagination's own defaults now derive from those constants rather than restating 0 and 10.
Pagination's pageSizeOptions is typed readonly number[], since the component only maps over it and a mutable type would reject the shared options for no reason a caller could act on.
Two lists stay off the hook and say why where they declare their state. The entries list is 1-indexed because that is what its API takes, and converting at every read and write trades one clear boundary for a class of off-by-one. The relationship picker accumulates results rather than paginating them: its page number only increments, results append, and there is no way back to a previous page.
- #840 `f5a5405` Thanks @mobeenabdullah! - Add
GET /api/admin-meta/workspace, a session-gated route serving the admin metadata that describes the installation: mounted plugins and their contributions, configured locales, custom sidebar groups, and builder availability./api/admin-metastill serves these alongside branding until the admin reads them from the new route, so nothing is withheld from an anonymous caller yet.
- #783 `376a3a4` Thanks @mobeenabdullah! - Repair the blog template so a scaffolded project type-checks and builds.
A new blog project failed its build at the search-index step: it has no posts, so Pagefind indexed nothing and exited non-zero. The empty case is now reported and skipped, while a real Pagefind failure still stops the build.
SQL statement splitting no longer tracks string state through comment text. An apostrophe in a retained comment opened a string that never closed, which merged every following statement into one that SQLite rejects.
The query layer narrows documents with runtime-checked readers instead of asserting them to its domain types, and a collection can declare defaultColumns in code as the admin and the visual schema already allowed. That option is now also carried through collection sync, which previously rebuilt the persisted admin shape in two places and dropped it in both.
Type change worth reading before upgrading: FindUsersArgs no longer inherits
the FindArgs options that users.find() does not implement — where,
status, sort, select, populate and pagination. Passing them compiled
and did nothing, so a where clause intended as an exact lookup returned the
first arbitrary user; code that passes one will now fail to compile. Use
search, or read a page and compare the field directly.
- #785 `8dc013e` Thanks @mobeenabdullah! - Refuse to start when boot migrations did not run. With
db.runMigrationsOnBoot, - an instance that could not take the migrate lock before its wait deadline used
- to log
Boot migrations complete (0 applied)and serve traffic —appliedis 0 - there and 0 on an up-to-date database, so nothing distinguished them. On a
- rolling deploy that is the second replica serving against a schema it never
- migrated. It now fails startup, which an orchestrator retries once the other
- instance finishes; a genuinely stale lock is cleared with
-
nextly migrate --force-unlock.
withMigrateLock reports whether its body ran instead of returning undefined
for both "returned nothing" and "never ran", so every caller has to decide. Its
wait-timeout message said "proceeding without it" while returning without
running the migrations, and now says they were skipped.
- #768 `7ea3567` Thanks @mobeenabdullah! - fix(create-nextly-app): generate a build script that runs on Windows, and stop swallowing a failed search-index build
- #752 `59d84dd` Thanks @mobeenabdullah! - Add a command palette to the page-builder editor. Opens on
mod+k, searches the commands the host - supplies, and runs the one chosen. Commands are data rather than built in, so the palette holds the
- keyboard surface and the host keeps its own vocabulary.
- #754 `51ddce0` Thanks @mobeenabdullah! - point the builder dev watchers at
src, sopnpm devrebuilds again
tsup --watch defaults to watching ., and at that root it never notices an
edit: no Change detected, no rebuild, and an artifact byte-identical
afterwards. Measured back to back on tsup 8.5.0 — --watch . saw nothing,
--watch src detected the same edit and rebuilt it.
Nothing errored while it was broken, which is why it survived: the watcher logs
a successful initial build and then Watching for changes, and the only symptom
is the ABSENCE of a later build line in output that scrolls. Anyone debugging a
stale dist was debugging code that had never been rebuilt.
- #733 `24a3a4d` Thanks @mobeenabdullah! - add the page-builder editor shell
@nextlyhq/builder gains BuilderShell — the editor frame: an icon rail, one
switched left panel, the canvas slot, a fixed right inspector, and the bars
around them. Presentational by contract: it owns which panel is open and the
region widths, and owns nothing about the document, so selection arrives as a
prop.
Also exported: the shell's own decisions (LEFT_PANELS, PANEL_BOUNDS,
RAIL_WIDTH, MIN_SHELL_WIDTH, MIN_CANVAS_WIDTH) and the PreferenceStore
port a host implements to keep chrome preferences wherever it already keeps
preferences.
New subpath @nextlyhq/builder/styles.css carries the --nx-builder-* chrome
token layer. A consumer that renders the shell without importing it gets
unstyled markup.
- #682 `7b19d8a` Thanks @mobeenabdullah! - Add the op store's vocabulary and inverse derivation to the builder: every document change is one of four id-addressed ops, and the op that undoes it is derived from the state it was applied to rather than declared by the caller.
- #831 `7a23525` Thanks @mobeenabdullah! - Rank every canvas drop target on one collision scale, so which target claims the pointer no longer depends on how deeply the page nests.
- #813 `a6555f8` Thanks @mobeenabdullah! - Stop the page-builder canvas reflowing when a drag starts. Drop zones no longer grow from zero to six pixels on dragstart, so blocks stay where they are while you aim, and the insertion bar now paints above blocks that carry a stacking context of their own.
- #781 `cec9cc3` Thanks @mobeenabdullah! - Updating a field group now changes its table wherever the update comes from. The mounted PATCH route and the Direct API previously stored the new fields without moving the physical schema, so only the admin panel performed the whole operation. A companion-table transition that fails now refuses the update instead of recording it as done, and the Direct API can toggle a field group localized.
- #795 `faf7fd7` Thanks @mobeenabdullah! - Add
core/columnas a real block and restrictcore/columnsto accept only columns, so a column can carry its own width, background and alignment.
A block whose slot refuses it is now reported by the repair banner and repaired by WRAPPING it in the one type the slot admits, so a page stored with loose children in a columns row can be fixed without discarding them. The block library's Insert button applies the same drop rules a drag does, inserting into the nearest place that accepts the block and reporting when there is nowhere. Slots declare whether they lay their children out with flex or grid, so the canvas stops interleaving drop zones that would become cells of that layout.
A block can declare the parents it may sit under — parent, matching the field of the same name in Gutenberg's block metadata — enforced on the editor and the write path alike, with the repair banner offering to wrap a stray block in the parent it names. This is the half a slot's allow list cannot express: a slot naming a type must not confine that type to it, and a block that is meaningless outside one parent has to say so itself.
It is declared on @nextlyhq/blocks-engine's BlockDefinition, so it reaches plugin authors through @nextlyhq/plugin-sdk/blocks alongside every other block field. A contributed block's nesting rules are enforced wherever the engine registry is populated — the write validator, the repair finder and the node constructor resolve a block's slots and permitted parents through it when this package's own registry does not hold the block. Not yet in the browser editor: blocks are registered by a plugin's server-side init, and the admin's client config transports only remotePatterns, so the browser realm's registry is empty and the canvas applies no contributed rule. Enforcement therefore holds at SAVE and not during editing, which is the safe direction — a document the editor let you build is still refused rather than stored — and it is a gap rather than a design. Slot allow-lists honour the engine's namespace wildcard (core/*) wherever they are read, rather than only exact names.
core/column uses parent so inserting a Column while one is selected produces a sibling in the row rather than a column nested inside a column.
blocks.manifest.json carries parent, and its manifestVersion moves to 2. That artifact is read by editor builds and by agents to decide where a block may legally sit, so omitting the field would not have made the restriction lenient — it would have told every reader there was none, and they would generate placements the write validator then refuses. The bump is required rather than cautious: the entry schema is strict, so a v1 reader rejects an entry carrying the new field outright.
The block library's Insert button now reaches a container's NAMED slot, not only default, so a container the drag path accepts is no longer refused by the click path. Documents are migrated when the editor loads them, which is what makes any block's migrate reachable at all — and migration only ever moves a document forward, never stamping an older definition version onto data written by a newer one.
The slot rules are now enforced in the editor's reducer, so paste, keyboard reorder and anything added later cannot write a document the save path refuses — previously only drag-and-drop consulted them. Documents are migrated when the editor loads them, which is what makes any block's migrate reachable at all.
Every drop target on the canvas now ranks by its depth in the tree, rather than only the zones between children doing so. A droppable that names no collision priority keeps the one its detector assigned — 3 with the pointer inside it, 2 otherwise — and dnd-kit compares priority before collision type and before overlap, so those targets outranked every zone shallower than that constant however the rectangles lay. The insert-before and append targets carried on each block were in that state, which put a nested container's own append target at or below the zones of the container holding it. They now read the same depth the zones do, so nesting decides which container claims a drop and geometry decides only where depths tie.
Fixes a crash opening an Image's aspect-ratio control: Radix refuses a select item whose value is the empty string.
- #766 `29e8129` Thanks @mobeenabdullah! - The field-group storage migration lock is now part of the schema Nextly reconciles. It was created on demand and declared nowhere, so it sat outside every migration: a change to that table could never reach an installation that already had one, because the statement that creates it does nothing to a table that exists. Nothing about the lock behaves differently today; what changes is that it can be maintained at all.
- #758 `fb9a0c0` Thanks @mobeenabdullah! - Show a disabled plugin's permissions on its detail page. They are seeded and
- granted whatever the plugin's enabled state, so withholding them made the page
- disagree with the database. Routes stay withheld — those genuinely are not
- mounted — and are disclosed separately as pending.
- #817 `5fc9cc7` Thanks @mobeenabdullah! - An email provider update no longer records a configuration change when a parser returns the same fields in a different order.
updateProvidercompared serialised text while the write path compares structurally, so a save that altered nothing could file a configuration-change entry in the activity log.
- #751 `e344e47` Thanks @mobeenabdullah! - The email delivery log is now bounded, and an erasure request survives a
- secret rotation.
The log records who was written to, identified by a digest of their address, and it grew on every send with nothing to remove it. The column that was meant to govern it and the index beside it were written and never read, so an operator reading a labelled retention class would reasonably have concluded something enforced it.
A sweep now removes rows past their window. It is offered by the SEND path rather than by a content write, because rows here are created by sends: that is when the table grows, a content write has no relationship to email volume, and an install that never sends mail carries no pass at all. Omitting the setting keeps a default window rather than keeping rows forever, since an unbounded record of recipients is not a reasonable default for a table an install fills without opting in.
This is the second half of erasure, and the halves cover different people. Erasing a named recipient only reaches someone a caller can name, and many recipients never had an account. The sweep reaches every row by age, whoever it belonged to.
Erasure also reached only rows hashed with the CURRENT secret. Rotating it left
older rows carrying a value the request no longer computed, so it matched
nothing and reported success — a privacy request that silently under-delivers.
Retired secrets can now be listed in \NEXTLY_SECRET_PREVIOUS\, kept for reading
and never for writing, and an erasure matches every digest those generations
could have produced. It accepts a comma-separated list for the ordinary case and
a JSON array for the secrets a comma-separated list cannot express — one holding
a comma or significant whitespace, \null\ for a generation that was unkeyed, and
\""\ for a secret that really was empty. Documented under "Rotating
\NEXTLY_SECRET\" in the environment reference.
Two things are deliberately unchanged. A send already in flight when a deletion commits still records its row; closing that would mean keeping a list of the addresses that asked to be forgotten, and the sweep bounds the row instead. And the retry columns stay inert: nothing drains this table, and a queue nobody drains looks durable without being so.
- #807 `8bb149f` Thanks @mobeenabdullah! - The Direct API now reports whether a field group is localized. A field-group update whose registry write fails after its companion table already changed is recorded with a new
divergedmigration status and reported as a change that stands, rather than raised as though nothing had happened.divergedis deliberately distinct fromfailed:failedmeans the table was never created and retrying is the repair, whiledivergedmeans the tables hold the new shape and the stored definition holds the old one, so the field group must be reconciled and the edit must NOT be retried. A diverged field group is refused for further schema edits until it is reconciled.
- #800 `7b23e26` Thanks @mobeenabdullah! - Updating a field group now refuses a field change that would need a column on its main table, pointing the caller at the schema preview and apply flow. Previously the request succeeded, recorded the new fields, and left the table without the columns it claimed to have.
- #745 `4c8d39c` Thanks @mobeenabdullah! - Retention no longer reads a sub-millisecond window as a request to delete everything.
A retention window is a whole number of milliseconds, so a fractional value is
rounded down. That rounding ran AFTER the check for zero, which meant any window
under one millisecond arrived as a window rather than as the zero it becomes:
\0.5\ was not zero when the check ran, and was zero by the time it was used.
On the audit trails a window of zero is treated as a mistake and replaced by the default, because erasing the record of who did what on a typo is not recoverable. That protection was reachable only by writing exactly zero. A value that rounded to zero skipped it and produced a cutoff of the current moment, which removes the entire trail on the next pass.
The rounding now happens before the reading, so a window is judged as the value it actually resolves to. A delivery ledger set to a fraction still keeps nothing, which is that trail's own position on zero and unchanged.
- #748 `a5ab500` Thanks @mobeenabdullah! - Label both ends of a date range, instead of relying on a placeholder that never renders.
A date input paints its own dd/mm/yyyy format hint and ignores placeholder outright, so a range written that way drew two identical empty boxes with nothing saying which end was which. The same spelling renders correctly on text and number inputs, which is why it survived: the defect is specific to one input type and invisible in the source.
Both date ranges in the admin -- the condition row and the entries filter menu -- now use one RangeField with real <label> elements bound to their inputs, and the pair is exposed as a named group. The filter menu had no accessible name on either input at all.
- #850 `9cdbbe1` Thanks @mobeenabdullah! - An interrupt during a legacy migration-lock claim now waits for the claim to settle before releasing it, so a shutdown no longer clears the row while the claim is still landing.
- #757 `d6f526e` Thanks @mobeenabdullah! - Give
DataTableViewapaginationprop and let the table place its own pager.
A pager's placement depends on whether the row table or the mobile card view is showing, and DataTableView is the only component that knows: the pager sits inside the card on desktop and takes the column's gap on mobile. Every list used to build the pager markup itself and hand it over, which left that decision at the call site — where the wrong arrangement is the one you get by writing the markup in reading order, and where several surfaces had drifted into it.
Tables now pass pagination as data: currentPage, pageSize, onPageChange and the rest, typed as the pager's own props rather than a restatement of them. A caller supplying state has no opportunity to place the control, so the mistake is no longer available to make. API keys, deliveries, webhooks, collections, field groups, singles, roles, users, plugins, email providers, email templates, image sizes, entries and the media list view are all on it, and MediaListView forwards the prop rather than a node.
Two surfaces keep rendering a pager directly, and say why where they render it: the media grid, which has no row-versus-card view to place one for, and the user-fields list, whose drag-reorderable rows are drawn by a DndContext over a plain table rather than by DataTableView.
Two fixes found along the way. Choosing a larger page size on the image sizes list left the page number pointing past the end, showing the empty message over a list that had rows. And the media library's two pagers now carry distinct accessible labels rather than both announcing themselves as "Pagination".
- #773 `7948d1f` Thanks @mobeenabdullah! - fix(create-nextly-app): keep pnpm add working in a pnpm scaffold
- #771 `fc92a4d` Thanks @mobeenabdullah! - Decide a boxed BigInt by its internal slot rather than by
Symbol.toStringTag, so a document cannot tag itself unstorable, and skip the whole-document serialization for a document the engine already refused as too large.
- #846 `f29ebeb` Thanks @mobeenabdullah! - A schema sync on a database whose migration-lock table predates its expiry column now holds that lock by owner instead of running without one.
- #777 `9a291fe` Thanks @mobeenabdullah! - The field-group migration lock now expires. A run renews its claim while it works, so a run that crashes or is killed no longer leaves a lock only an operator can clear, while a run that is still working keeps the lock for as long as it needs it. A run whose claim is taken over or can no longer be renewed fails loudly instead of continuing unprotected.
- #838 `b58f55c` Thanks @mobeenabdullah! - A schema sync now reports a migration lock it had to skip, and a run whose lock renewal never answers fails instead of hanging.
- #833 `a0e2817` Thanks @mobeenabdullah! - Make one control size name mean one control height.
size="sm" resolved to --nx-control-height-md (36px) on Button and --nx-control-height-sm (32px) on Input and SelectTrigger, so a small button beside a small input or select sat 4px out of line. default and lg already agreed; only sm diverged.
Input and select now take the same step as button. Nothing changes visually today: there was not one <Input size="sm"> or <SelectTrigger size="sm"> anywhere in the repository, which is why the divergence survived — it was waiting for its first call site rather than showing up on a screen. Aligning the other direction would have shrunk sixty live buttons to fix a case nobody had hit yet.
A test now calls the exported cva functions and asserts that every size name shared by these primitives resolves to the same height token, and that the steps stay ordered. It reads the class string a caller actually receives rather than parsing the variant maps out of the source.
The admin sidebar's search field asked for h-9 directly, which happened to equal the small step and then stopped tracking it. It takes size="sm" now, and its icon is centred rather than offset by a fixed top-2.5 that only centred inside a 36px control — the same height decision written a second time.
- #857 `224c729` Thanks @mobeenabdullah! - Declare the admin's session-free routes once.
Which routes are reachable without a session was answered in three places: the
page registry, a hand-kept set in the refresh interceptor, and the
pages/(auth)/ directory. A page added to the registry but missed in the
interceptor still rendered, but its expected 401 redirected to login and
discarded the URL, which is how an invite token was once lost.
PUBLIC_ROUTE_PATHS in constants/routes.ts is now the declaration. The
registry keys its public pages by that type, so the two cannot disagree without
failing the build, and the interceptor derives its set from the same array. A
test reads the (auth) directory, which no type can reach, and fails on a page
nobody declared. No behaviour changes.
- #743 `b55e278` Thanks @mobeenabdullah! - Retention now keeps what you asked it to keep.
Setting a retention window to Infinity — the strongest way the type allows you
to say "keep these forever" — was deleting instead. Audit trails were removed
after 90 days and webhook events after 30, on the schedule the default sets,
while the setting itself read as accepted. Nothing surfaced it: the pass ran,
reported success, and pruned rows the configuration had asked to retain.
The cause was two separate answers to one question. Audit and webhook retention each resolved a configured window in their own file, and the two had drifted: a 2000-year window kept everything, an infinite one deleted, and the same input produced different outcomes depending on which trail it was written for. Webhook retention also had no upper bound at all, so a very large window produced a cutoff date no database column can store, which made the pass fail silently on every run and leave the ledger unpruned.
There is now one resolver behind both, built on the rule they disagreed about: refusing a value must never delete more than accepting it would. An infinite window, and any window longer than a date can express, now mean keep forever. Values that ask for less than the default, or for nothing coherent, still fall back to the default, because that direction cannot lose data.
How long a window a trail can express is stated by the trail rather than shared, because it is set by the column the cutoff is compared against and those differ. Content activity is compared against a column counting from 1970 and so tops out around fifty years; the audit, event and delivery trails count from a calendar year and accept far longer windows. Sharing one ceiling would have meant a window a column can hold being answered with "never prune", which is unbounded growth on a setting that asked for the opposite.
Two positions each trail holds on its own are unchanged: false still means
keep forever everywhere, and a delivery ledger set to zero still keeps nothing,
which is a real choice for a table whose only purpose is making a retry
possible.
- #779 `332d56e` Thanks @mobeenabdullah! - Write a block node's own fields in the declared order when an op rewrites it, so undoing a removed field restores the document rather than only its values.
- #856 `f7545fe` Thanks @mobeenabdullah! - Disclose a plugin's retired permissions on its detail page instead of omitting
- them.
The permission list endpoint now forwards includeOrphaned, so a caller that
reports what a plugin owns can ask for rows nothing declares any more. They are
shown marked rather than hidden: the row still exists and still carries its
grants, so leaving it out understated what a plugin left behind. Lists that
OFFER permissions are unchanged, because the option is off unless asked for, so
the role permission matrix still shows only permissions that enforce something.
- #809 `e19f31a` Thanks @mobeenabdullah! - fix(nextly): persist the admin options a collection is allowed to set
order and sidebarGroup were accepted by CollectionAdminOptions and dropped by the projection that writes the registry, so a code-first collection could set its sidebar position, type-check, and still sort by the default. admin.description had no column under admin at all; it now resolves to the collection's own description, which is the field the admin already renders and the Schema Builder already edits.
A compile-time assertion now requires every admin option to be either persisted or listed with the reason it is not, so adding one forces the author to classify it in the same change. That list is exactly what drifted twice before.
- #747 `c92db86` Thanks @mobeenabdullah! - Reject duplicate plugin admin slugs at boot.
pluginAdminSlugcollapses every - non-alphanumeric run to a single dash, so distinct package names can map to one
- slug and the plugins then share a single admin address — one plugin's detail
- page opens the other's, and host
pluginOverridesapply to the wrong package. - No lookup downstream can detect this, because every lookup along that address
- returns a plugin.
resolvePluginsnow refuses to start, naming both packages - and the slug they collide on.
- #762 `e24638c` Thanks @mobeenabdullah! - Warn at boot when a plugin ships without an
admin.description. Without one the - admin can only show the package specifier wherever it lists that plugin, and
- nothing previously stopped a plugin shipping that way.
- #749 `2f2f089` Thanks @mobeenabdullah! - Give the installed plugin detail page a two-column layout with a sticky
- metadata rail. About moves into an aside beside the contributions rather than
- below them, so what a plugin adds — its permissions and API routes included —
- stays visible while its metadata is read.
- #742 `d4f6480` Thanks @mobeenabdullah! - The admin now has a plugin directory, at Plugins then Browse plugins.
It lists the plugins Nextly publishes with a description, category and author, marks the ones already installed, and searches by name, description and tags. A curated row sits above the grid while there is more in the grid than in the row.
It is discovery only. Installing a plugin means adding a dependency and a line to nextly.config.ts, so the directory never writes to your source or changes plugin state. Where a listed plugin is already installed, its own icon and description are shown rather than the directory's copy of them.
- #753 `85d526e` Thanks @mobeenabdullah! - Disclose the routes a disabled plugin would serve once enabled. A disabled
- plugin mounts no routes, so
routesstays empty and the same declarations - travel as
whenEnabledinstead — only those that would actually mount, checked - by the same fold that mounts them. Its permissions are untouched by this: they
- are seeded whatever the plugin's enabled state, so they were never pending on
- anything.
- #826 `f0b9f1d` Thanks @mobeenabdullah! - Show a plugin's permissions on its detail page again, read from the
- authenticated permissions endpoint rather than the public admin-meta payload.
These are the rows the seeder actually created, which is a different set from
the declarations: a publish or unpublish declaration naming a collection
or single is dropped, because the seeder emits that slug itself and keeps the
row ownerless. The page now reports what exists rather than what was asked
for, and it reports nothing at all when the request fails instead of showing
an empty section.
- #842 `4fdbf77` Thanks @mobeenabdullah! - The entry editor now offers Copy shareable link.
The preview-link machinery already shipped — a mint route gated by update, an admin service, a usePreviewLink hook and the PreviewActions control — but nothing in the standalone editor rendered any of it: the control was wired only into the form footer, which the editor renders in embedded (modal) layouts alone. An author had no way to reach the feature.
The control now sits in the editor's action bar, directly left of Save, for a saved entry whose author holds update on the collection. The permission half of that condition is resolved by the header itself rather than by each caller, so the gate cannot be omitted by a future call site.
- #845 `1b0689e` Thanks @mobeenabdullah! - Serve only branding from the public
/api/admin-meta. Plugin contributions, configured locales, custom sidebar groups and builder availability now come from the session-gated/api/admin-meta/workspace, so a plugin-declared permission slug is no longer readable before sign-in. The admin reads both and merges them, so no component changes.
- #823 `5244934` Thanks @mobeenabdullah! - Stop serving plugins' declared custom permissions on the public
-
/api/admin-metapayload. That endpoint answers without authentication, so - every plugin action and resource name it carried was readable by anyone who
- could reach the app.
The plugin detail page no longer lists a plugin's permissions. Reading them from an authenticated endpoint is a separate change and is not in this release.
- #738 `2f3bb57` Thanks @mobeenabdullah! - The block document format now publishes a JSON Schema, so a generator, an editor
- build or an agent can check a document against the format without TypeScript.
- #737 `791a08e` Thanks @mobeenabdullah! - A field-group storage migration dry run no longer writes anything. It observes the migration lock instead of claiming it, so a preview works with a read-only database role, and reports what it could learn about the lock as
lockon the dry-run outcome rather than refusing when another run is in flight.lockis{ kind: "held", owner },{ kind: "not-held" }or{ kind: "unknown", reason }— an unreadable lock table is reported as unknown rather than as nothing holding the lock.
Because a preview takes no lock, another run can advance between its reads and leave it scoring the plan against a state the database was never in. A dry run now re-reads and retries when that happens, and the outcome carries basis to say which answer it ended up with: { kind: "reconciled" } when the plan was scored against the live catalog, or { kind: "unreconciled", reason } when a writer kept moving underneath it. An unreconciled preview still reports every rename the migration declares rather than an empty list, so it can never be mistaken for "nothing to do". Refusals that re-reading cannot clear are ultimately preserved: a torn-shaped but persistent conflict now spends its attempts confirming the database is not moving before the refusal stands, so a conflicted database sees the extra catalog reads that stability check costs.
- #789 `0b3fc78` Thanks @mobeenabdullah! - Fix the scaffold job's workspace-package pin, and fail closed on an unreadable
- search-index manifest.
The pin rewrites dependency specifiers after the scaffold has generated its lockfile, and pnpm turns frozen-lockfile on by default in CI — so the pnpm blog leg aborted with ERR_PNPM_OUTDATED_LOCKFILE before it could build.
An index manifest that exists but cannot be parsed no longer reads as owning nothing. writeFileSync is not atomic, so an interrupted build can truncate it, and treating that as an empty ownership list left the previous index in place while the status flipped to empty — the search page would load and serve unpublished results.
- #791 `20c1d43` Thanks @mobeenabdullah! - Stop generating a
db:migrate:resetscript that names a command the CLI does not - register. Every scaffolded project shipped an
npm run db:migrate:resetthat - failed;
db:migrate:freshalready drops all tables and re-runs the migrations.
- #759 `e520db5` Thanks @mobeenabdullah! - A Schema Builder change to a single or a field group now holds the field-group storage migration out for its whole duration, rather than being able to start one halfway through. The exclusion is taken before the change plans anything, so a create, an update or a delete either runs against storage nothing is renaming or is refused
- outright — and a change that is refused has written no row and built no table of its own. Taking
- the exclusion can still create the migration lock's own table, which is empty, holds no content,
- and would have been created by the next successful change anyway. A database that has never run a migration is covered too: these paths may create the lock table, so a first migration cannot claim it and start renaming underneath a change already in progress.
Not every way of changing schema is covered yet. The Admin's confirmed apply, the standalone schema routes, collections and user fields still write without the exclusion, so they can run alongside a storage migration.
- #801 `d9bbcf6` Thanks @mobeenabdullah! - Toggling a field group between localized and not now advances its schema version, so a Schema Builder tab opened before the change is told to reload instead of overwriting it. Previously only a field change advanced the version, and the toggle moves columns between tables.
- #739 `b09b087` Thanks @mobeenabdullah! - Make the admin search field an
Inputrather than a second implementation of one.
SearchBar restated Input's classes instead of composing it, and the copy had drifted twelve ways: no aria-invalid or data-[invalid=true] handling at all, so a search field could not show an error state; focus:border-primary without the ! Input uses; and no selection:* colours, placeholder:opacity-50 or disabled:pointer-events-none. Palette work reached every input except this one, because the border token was named in two places and only one was maintained.
The field is also type="search" now, so assistive technology announces it as one.
Its className reaches the wrapper, not the field, so the border-input and border-border classes eighteen call sites passed were inert. Those are removed, and in development the component now names any it receives so the next one is visible rather than silent.
That warning judges the class string the element actually receives, and only reports a class that does nothing on the box as rendered: give the wrapper a border and a border colour paints, give it padding and a background shows around the field, and in each case the class is left alone.
Input also sets its own text colour now. It set one for file inputs and for placeholders but never for the field's own text, so it inherited whatever surrounded it — which Tailwind's preflight resets to inherit on form controls.
- #761 `7133efb` Thanks @mobeenabdullah! - Load template and playground fonts from packages instead of fetching them from Google Fonts during the build.
- #784 `eefb655` Thanks @mobeenabdullah! - Give a list's page state one implementation.
Thirteen places in the admin held the same two lines: set the page size, return to page one. The copy that drifted meant choosing a larger page size from a later page asked for rows past the end of the list, and the table rendered its empty message over a list that had rows.
usePagination owns page and size together, so the resets travel with the state rather than with each caller: a size change returns to the first page, and resetPage covers a search or filter change that alters which rows exist. Both settings move in one update, so a query keyed on them refetches once rather than once per setter. useServerTable derives from it rather than restating it.
- #767 `9a8d259` Thanks @mobeenabdullah! - fix(create-nextly-app): declare
packagesin the generated pnpm-workspace.yaml so scaffolded projects install on pnpm 9
- #770 `dd3eafd` Thanks @mobeenabdullah! - fix(create-nextly-app): ship the template .gitignore through npm packing, so a new project does not commit its .env
- #797 `ec9b4c7` Thanks @mobeenabdullah! - Bound block-document validation by the limits the survey enforced, so a caller passing a limits object whose values change between reads can no longer make the walk outrun the cap that was checked.
- #721 `a398047` Thanks @mobeenabdullah! - Tabs now look the same everywhere.
The admin's tab strips are an underline control: the active tab is marked by a bottom border, and the tab is square so that border runs flush to its edges. The shared component already draws all of it — the underline, the active and hover colours, the focus ring.
Several first-party plugin screens were drawing their own instead. The form builder switched the underline off and repainted it from React state through an inline style, three field-editor tabs restated the whole indicator, and a few places re-declared a square corner the component already guarantees. The result was the same component wearing a different appearance depending on the screen.
Those screens now pass layout only and let the component draw the indicator, so the page builder's inspector, the form builder, its field editor, its preview and its submissions list all match the rest of the admin. Layout overrides stay allowed, because a tab strip in a dialog is a different shape from one in a sheet.
A test reads every first-party call site and reports one that repaints the indicator, so the next screen to do it is caught in review rather than noticed later. It reads what a call site is written as, which is not the same as guaranteeing the appearance cannot be forked: a class arriving from another module, through a prop spread, or through a slotted child is not something it can see. The component stays deliberately overridable so a theme can move these values, and that is the same door a call site can walk through.
- #821 `d011d54` Thanks @mobeenabdullah! - Render a table's custom footer beside its pager rather than instead of it.
DataTableView resolved its footer slot as pagination ? pager : footer, on the reasoning that two pagers in one slot is not a composition anyone wants. But footer takes an arbitrary node rather than a pager: a caller using it for a selection summary or bulk actions and then adopting pagination lost that content, with both props public, both permitted by the type, and nothing reporting the loss. Both render now, footer first, since a summary describes the rows above it and the pager moves between them.
Also removes a comment in the media library that explained the grid pager's accessible label by what a source-level placement guard needed. That guard was deleted in the same release, so the comment described nothing; the screen-reader reason is the real one and is kept.
- #828 `e5e4023` Thanks @mobeenabdullah! - Tabs gain
TabsList variant="ghost"andTabsTrigger size="sm", so the compact tab appearance is named rather than spelled out inclassNameat each call site. The two call sites that hand-rolled the ghost list disagreed on its height (h-8andh-7); the variant settles it ath-8.
- #778 `d3e487a` Thanks @mobeenabdullah! - Store the configuration a provider parsed, and refuse a write whose parse is not a fixed point.
The service persisted whatever the caller submitted while the adapter closed over the parse result, so every difference between the two became a defect somewhere that read the row. It now persists the parsed value, and checks before writing that parsing the stored form returns the stored form -- rejecting a parseConfig that derives a credential, returns a value JSON cannot carry, or refuses its own output, each of which would otherwise hand the adapter a configuration nobody saved.
- #804 `a88d6c5` Thanks @mobeenabdullah! - Ignore the config copies tsup writes for a package that builds more than one bundle. A watcher stopped with Ctrl-C left a tsup.<name>.config.bundled\_\*.mjs behind that no ignore rule covered, and the next lint failed with a parsing error naming a file nobody wrote.
- #750 `36825d4` Thanks @mobeenabdullah! - Start both build watchers of
@nextlyhq/uion every platform. Thedevscript used a POSIX - background-and-wait, which
cmd.exeruns sequentially, so on Windows the first watcher held the - line and the server-safe artifacts were never rebuilt — with no error, no exit code and no output.
- #741 `02ade17` Thanks @mobeenabdullah! - Convert
packages/ui's build scripts to TypeScript and delete the hand-written - declaration files beside them. Nothing kept a
.d.mtsin step with the module - it typed, so a test compared the two — and that comparison had to model every way
- ECMAScript can publish a name. There is no second list to drift now, and the
- scripts are type-checked for the first time.
- #803 `40dfd52` Thanks @mobeenabdullah! - fix(nextly): paginate users by user rather than by role-joined row
listUsers applied LIMIT/OFFSET to a query that left-joined user_roles and roles and grouped afterwards, so a user holding three roles consumed three rows of the page. A page of N therefore returned fewer than N users, and OFFSET advanced over joined rows rather than users — which skipped users entirely rather than merely short-filling the page. Measured on nine users with two holding three roles each: walking every page visited six of them.
The page query now selects one row per user and roles are fetched for exactly the users that page selected, so total keeps counting the same thing it always did and a page of N contains N distinct users. Role order per user is now deterministic; the join left it to the planner.
- #799 `5ff805e` Thanks @mobeenabdullah! - Add validateDocument, which returns the survey a validation judged a block document with, so a caller can ask whether the engine measured it in full instead of inferring that from issue codes. validate keeps its signature and becomes the narrow view over it.
- #799 `5ff805e` Thanks @mobeenabdullah! - Report which of three things JSON does to a block document instead of one flag for all of them. A document JSON writes but rewrites - an array hole, a dropped key, a negative zero - is no longer refused as having no stored form, and a document the validator declined to read is reported as unmeasured rather than as unwritable.
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/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.57
AlphaReleased 19 packages at 0.0.2-alpha.57 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #714 `5673fff` Thanks @mobeenabdullah! - The admin now ships with rounded corners and the Geist typeface. Corner radius comes from a single
--radiusknob, so changing that one declaration re-rounds the whole panel, and a plugin built against the published Tailwind preset re-rounds with it.
- #699 `6936078` Thanks @mobeenabdullah! - Add an experimental BreakpointDialog to @nextlyhq/ui, with the validation behind it. The style compiler discards a breakpoint it cannot use rather than raising, so a bad definition is lost silently and surfaces later as stale styles; the dialog refuses to save any set that would lose one.
- #728 `38e5e6b` Thanks @mobeenabdullah! - move the breakpoint editor into the builder, where its rules can be derived
lib/breakpoints.ts and breakpoint-dialog.tsx restated the style compiler's
breakpoint drop rules because @nextlyhq/ui is the block-agnostic layer and
cannot depend on @nextlyhq/blocks-engine. Two implementations of one rule
agree the day they are written and drift silently after.
They now live in @nextlyhq/builder, which already depends on the engine and
imports MAX_BREAKPOINTS_PER_AXIS and the breakpoint types from it rather than
mirroring them.
Breaking, and deliberate: the @nextlyhq/ui/breakpoints subpath is removed,
along with BreakpointDialog and the breakpoint types from the root barrel.
Nothing in this repository imported them, and every affected export was
@experimental.
- #683 `5bfac2f` Thanks @mobeenabdullah! - Add the builder's host-canvas coordinate mapping: one module converts between the canvas frame and the host page, including the scaled border inset that places the frame's content origin. A sibling test scans for cross-frame rectangle reads elsewhere in the package, recognising a bounded set of spellings; it narrows the paths taken by accident rather than enforcing single ownership.
- #717 `5a05e7b` Thanks @mobeenabdullah! - Add an experimental ColorPicker to @nextlyhq/ui, with the pointer-to-colour geometry behind it on the server-safe @nextlyhq/ui/color entry. The picker knows nothing about design tokens: a swatch carries an opaque value it hands back untouched, so a host storing a token reference keeps it rather than receiving the colour that token happened to resolve to.
- #713 `dbd95b3` Thanks @mobeenabdullah! - Erase a recipient from the email delivery log.
Deleting a user left their delivery rows behind carrying a keyed hash of their
address, which an install holds the key for, so the table went on answering
"was this person written to, and when" for an account that no longer exists.
eraseRecipientDeliveries overwrites that hash with a value no address can
produce, keeping the row, its status and its timing so aggregate questions
still have an answer. deleteUser calls it inside its existing transaction, so
a failed erasure takes the deletion with it rather than leaving the two out of
step.
The erasure takes an ADDRESS rather than a user id, because most recipients
never had an account: a password reset to an address that never registered, a
CC, a BCC added by a beforeSend filter. Those people can ask to be erased too
and no account deletion will ever fire for them, so it is callable directly.
EmailDeliveryRecord.recipientHash is now string | null, where null means
erased.
- #734 `193d5ec` Thanks @mobeenabdullah! - Advertise the Node range this project actually supports. Every package declared
-
>=20.0.0while the repository requires^20.19.0 || ^22.12.0 || >=24.0.0, so - installs on 20.6-20.18 or on 23.x succeeded without warning and failed later at
- runtime. Release preflight now derives the expected range from the root manifest
- and rejects a package that disagrees, so the two cannot drift apart again.
- #722 `696281d` Thanks @mobeenabdullah! - Field group instances now report their stored type through
nextly/field-group-type, a new entry point that reads whichever spelling a document carries and writes the current one. The admin editor uses it, so content saved before and after the storage rename stays readable and selectable in both.
- #700 `cf04a67` Thanks @mobeenabdullah! - Correct the frame content origin to include the iframe's padding, and measure that inset in one place.
An iframe's nested viewport begins at the content box, so padding displaces it exactly as a border does. Callers built the inset from clientLeft/clientTop, which report the border alone, so every frame-local point mapped toward the border by the scaled padding. frameInsetOf is now exported as the single reader, and both the README recipe and the FrameGeometry documentation name it instead of restating arithmetic three call sites had already got wrong.
- #689 `213a860` Thanks @mobeenabdullah! - Admin list pages now attach their pagination to the table it belongs to, instead of leaving it floating a row below the table on some pages and attached on others. Applies to users, plugins, roles and webhook endpoints.
- #725 `73885c6` Thanks @mobeenabdullah! - The field-group storage migration can now report what it would rename without changing any content or recording that a run happened, and refuses to run for real unless the caller states that a restorable backup exists. A preview still claims the migration lock, so it needs a role that can write to Nextly's own lock table.
- #719 `f61172e` Thanks @mobeenabdullah! - A stylesheet stored for a page is no longer reused when a block migration has
- since turned one of its nodes into one that renders nothing. The rules compiled
- for that node, and any image the rules fetched, were still being served for
- markup no visitor receives.
- #730 `6683ef3` Thanks @mobeenabdullah! - Plugin icons now resolve through one shared rule, so the same plugin shows the same icon everywhere in the admin, and a plugin can ship its own logo image instead of naming a built-in glyph.
The SEO plugin now describes itself in the plugins list instead of showing a bare package name.
A styling fixture used only by the end-to-end suite no longer appears as an installed plugin, and no longer injects a showcase section into the Posts collection list, in a normal development server.
- #740 `db7122d` Thanks @mobeenabdullah! -
@nextlyhq/plugin-sdknow exportspluginAdminSlug,PLUGIN_CATEGORIESandisPluginCategory(experimental), so a plugin author can derive a plugin's admin slug and check a category against the vocabularydefinePluginaccepts, rather than reimplementing either. They are also onnextlyandnextly/configfor host apps.
The admin uses those exports instead of its own copies. It previously derived a plugin's URL slug with its own implementation of core's algorithm, so a plugin page could be linked at one slug and routed at another the moment either side changed, and it kept its own list of valid categories, so it could reject a category definePlugin accepts.
Nothing changes in the admin UI. The plugin directory that consumes these is not built yet; this is the groundwork it needs.
- #727 `53fca3e` Thanks @mobeenabdullah! - On desktop, the Plugins item in the admin sidebar now opens the plugins page when you click it, instead of only expanding the sub-sidebar and leaving you to find the page yourself. On mobile it still opens the panel, as every sidebar section with a panel does, and Installed Plugins is the first entry inside it. The item also stays visible when no plugins are installed, so a new project can reach the plugins page at all.
Users who can read a plugin's collections but cannot manage settings keep the sub-sidebar, since the plugins page itself is settings-guarded.
The secondary sidebar now closes when the category it is showing stops being one of the sidebar's destinations, so a slow or failing permissions load no longer leaves an empty panel open beside the page.
- #671 `75054a8` Thanks @mobeenabdullah! - Relationship expansion can now be told WHICH collections a trusted read may
- reach, judged per expansion target.
overrideAccess says the caller is trusted. It said nothing about the
collection a relationship points at — which the caller never named and may not
serve to the same audience — so a trusted read spread that trust into every
target it populated. A caller serving one fixed audience can now state its
trusted set, and anything outside it is read as that audience would read it.
Absent the new option nothing changes, so the Direct API keeps its semantics: a caller that has already decided who is asking is not narrowed by a default it never chose.
- #724 `35ff30a` Thanks @mobeenabdullah! - A page whose stylesheet is reused now keeps it when a block migration turns a
- condition-gated node into one that renders nothing. Those nodes never had rules
- in the shared sheet, so withholding it cost every other block on the page its
- styling.
- #673 `67082d1` Thanks @mobeenabdullah! - Check the built server-safe entry points against what the build recorded, and stop publishing the
- bundler metafiles those checks read.
The gate reads two records the build already wrote — the module specifiers surviving in each artifact and every chunk reachable from it, and the bundler's own metafile of what it inlined. A bundled dependency leaves no import to find, so the text alone cannot answer what an artifact reaches. The metafiles are build inputs to that check rather than something a consumer needs, so they are excluded from the published files.
- #702 `8011731` Thanks @mobeenabdullah! - fix(ui): ignore a dispatched event that is not a keystroke
The shortcut manager listens on document, so every event dispatched anywhere on
the page reaches it — including synthetic ones from code outside the application.
A password manager typing into a credential field dispatches a keydown carrying
no key, and the manager spread it as a string, crashing the page with
TypeError: key is not iterable. It now ignores an event it cannot read as a
keystroke, and leaves it propagating to whichever listener does understand it.
- #697 `ca1cc48` Thanks @mobeenabdullah! - Carry a trusted write's bound into a Single's upload expansion. A Single holding uploads and no relationship field returned whole media rows in its write response, because the bound reached only the relationship expansion beside it, which returns early for such a document.
- #705 `ecefaa2` Thanks @mobeenabdullah! - A field group instance now reports its type whichever spelling the stored document uses, so content written before and after the storage rename both read. A
wherefilter on the type keeps working under either spelling, and version snapshots keep recording the type of components nested inside a dynamic zone. Reading that type is one shared call rather than a key spelled out at each site, which is what keeps the rename a change in a single place.
- #716 `cf48bd7` Thanks @mobeenabdullah! - A version snapshot now records each field group instance under one spelling of its type key. An entry captured before the storage rename, restored, and captured again previously kept its old key alongside the new one, so the snapshot announced the same instance's type twice.
- #731 `298d41e` Thanks @mobeenabdullah! - Page builder inspector: keep the open panel tab in sync when the selected block changes type, so the inspector no longer shows a tab the block does not have.
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/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.56
AlphaReleased 19 packages at 0.0.2-alpha.56 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #633 `175ed53` Thanks @mobeenabdullah! - admin: render the email provider form from the server's provider descriptors
The provider form no longer knows any provider by name. It fetches the registered types and their field metadata from the server and builds the picker, the controls and the client-side validation from that, so a provider contributed by a plugin is configurable in Settings without editing the admin.
Dotted field names are treated as paths, so a provider declaring auth.pass
stores { auth: { pass } }, and a credential the user did not touch is
omitted from the update rather than overwritten with the mask that stood in for
it. A provider whose plugin has been removed renders read-only with its type
named instead of as a blank form.
Also fixes the Active toggle on the edit page, which was rendered and then left out of the update payload, so pausing a provider silently did nothing.
nextly: record who created, changed, promoted or deleted an email provider
email_providers holds the credentials that send password-reset and
verification mail, so an actor who can edit a provider can point every
authentication email at a relay they control. That action previously left no
record. Create, update, delete and promote-to-default now write an activity
entry naming the actor, the provider and which fields changed.
Names, never values: an entry carries no part of the configuration, and a
configuration change is recorded as the single field name configuration
rather than by its inner paths. An update that moved nothing writes no entry
at all.
The provider screens also tell a catalog that could not be loaded apart from one that merely could not be refreshed. A failed refresh keeps the descriptors already fetched, so the type filter, the row labels and the form all still work from them; the pages now say so instead of reporting the catalog unavailable, and the edit page's Update button follows the form into read-only when the cached catalog no longer lists the stored type.
Promoting a provider to default is one transaction. The demotion of the previous default and the write that promotes previously committed separately, so a promotion that matched nothing — a row deleted between the read and the write, an insert the database refused — left the installation with no default provider at all and nothing in the trail to say why.
Inside that transaction the demotion runs first. PostgreSQL carries a partial
unique index over is_default = true and checks it as each statement runs, so a
row taking the default while the incumbent still holds it is rejected outright.
A promotion that then matches no row — because the provider was deleted in the
meantime — throws rather than commits, which takes its own demotion back with
it.
A masked value is no longer written back over what it stood for. The read masks a configuration path the provider does not describe — a credential left behind by an upgrade, say — while the write stripped masks only from paths declared secret, so a client echoing the configuration it was given replaced the real stored value with eight bullet characters during an unrelated edit. Masking and unmasking now ask one question.
Only a handover opens a transaction. Wrapping every provider write in one cost
correctness on SQLite, where the transaction is BEGIN IMMEDIATE on a single
shared connection: a second ordinary write arriving while the first was open
could not begin at all.
An edit form left open reconciles a newer version of the record it is showing. The detail query refetches on focus, so a change made elsewhere used to be held and written back on the next save, reverting it from an edit that never touched those fields. Fields the operator has touched keep what they typed. If the record's TYPE changed, the configuration is rebuilt from the new provider rather than carried across — otherwise one provider's credential is submitted as another's wherever both declare the same field name.
A stored value that predates a tightened constraint no longer blocks unrelated
edits. A provider upgrade that lowers maxLength, or narrows a numeric range,
made every provider holding an older value unrenameable and undeactivatable. The
provider's own parser stays the authority on what it accepts; the descriptor
governs replacements.
Provider metadata that no descriptor can publish is refused at registration
rather than at the first request for the catalog: options that is not an array
of { value, label } on any field kind, two select options sharing a value, and
capabilities given as an array. One malformed provider previously took the
whole catalog endpoint down, and with it every provider's form.
- #653 `3709979` Thanks @mobeenabdullah! - Route the admin panel's keyboard shortcuts through the shared shortcut manager, so one listener owns every key and precedence follows the component tree rather than mount order.
- #644 `80ca19e` Thanks @mobeenabdullah! - Refuse an unknown URL scheme in a block's attributes instead of naming the dangerous ones.
The guard every block prop that reaches an href or a src passes through was a BLOCKLIST: javascript:, vbscript: and data: were named and refused, and everything else was allowed. So blob: was allowed — and a blob: document runs in the origin that created it, which is the page's own. So were filesystem:, about:, view-source:, and whatever a browser ships next. A blocklist has to predict every dangerous scheme and misses the one nobody had heard of when it was written, which is the same reason the style compiler and the remote-host policy are both allowlists.
Four schemes are accepted now: http and https for a destination, mailto and tel for the two that open an app rather than a page and are the ordinary content of a contact button. A value carrying no scheme is untouched, so /about, a.png, #top and //cdn.example/a.png all still work — which hosts may be REACHED is a separate question, asked of the host policy by the blocks that fetch rather than of a list of schemes.
These are the same four the rich-text sanitizer already allows, and that is deliberate rather than a coincidence: it answers this identical question for stored rich text, and two surfaces of one product disagreeing about which schemes are safe is how a value refused inside a link body becomes acceptable in a button beside it. The admin's link editor keeps accepting a wider set for what an author may TYPE, because that is an input affordance and not the boundary.
The scheme is read from the value as the browser's parser will read it, and through the ENGINE's normalisation rather than a second copy of the rules — two spellings of one algorithm disagreeing is how a scheme hides from a check while still navigating. Tab, LF and CR are removed wherever they appear because the parser removes them; leading control characters and spaces are trimmed because the parser trims them.
An interior space is deliberately NOT removed, because the parser does not remove one either — it percent-encodes it. hero image:1.png is an ordinary relative path to a file whose name holds a space, and collapsing that to heroimage:1.png would invent a scheme nobody wrote and refuse the path. A control character still sitting inside the value after normalisation refuses it outright instead: one never appears in a URL anybody meant, since it has to be percent-encoded to survive, and its only use here is to split a scheme so a reader sees none where a browser may still see one.
The value returned is still the original trimmed string, so a legitimate URL is never silently rewritten.
- #643 `07cd50f` Thanks @mobeenabdullah! - Page validation now refuses children stored under a slot on a block that holds none, not just on containers with the wrong slot name. Every block in the catalogue declares its structure where the check can read it without loading the block library.
- #636 `b4e032b` Thanks @mobeenabdullah! - Page validation now knows what slots a block declares without the block library having to be loaded, so a page saved through the normal server path is checked rather than waved through. Three layout blocks move to the new source in this change; the rest follow.
- #640 `19f35d9` Thanks @mobeenabdullah! - Every block that can hold children now declares its slots where page validation can read them without loading the block library, so a page saved through the normal server path is checked against all of them rather than a few.
- #691 `8f5d785` Thanks @mobeenabdullah! - Type-check
blocks-engine's test files, and stop Node globals reachingsrc.
Turning the check on surfaced a real defect in the published types:
AnyBlockDefinition widened every prop-consuming member except seo, so
registerBlocks rejected every definition built by defineBlock<P>() for any
interface P without an index signature — whether or not it contributed SEO.
seo is now widened like its siblings, so typed blocks register.
- #662 `18b529b` Thanks @mobeenabdullah! -
@nextlyhq/blocks-reactnow emits a prepared document's slots in the order the - block DEFINITION declares them, not the order they happen to be stored in.
The renderer asks for its slots by calling renderSlot once per declaration,
so declaration order is the order the page presents. This tree is documented as
the render-equivalent one, so carrying stored order left its own key order
describing a page nobody is served, and made two documents that render
identically compare as different.
A slot the definition declares but the document never stored stays ABSENT rather than being added as an empty array: an empty slot renders nothing either way, and adding it would rewrite every document that omits an optional slot.
- #687 `e1d573e` Thanks @mobeenabdullah! - The page renderer and the shared read pipeline no longer keep separate copies of the passes a stored document goes through before it is read. Nothing changes for a reader; the two could previously drift, and a reader that skipped the gating pass would publish content the page deliberately withheld.
- #651 `f054383` Thanks @mobeenabdullah! -
@nextlyhq/blocks-reactnow exports the types its public API is written in.
StyleCompileContext, BlockDocument and DocumentLimits appeared in the built
declarations in parameter positions while being named in no export statement,
and BreakpointSet — the one field StyleCompileContext requires — was absent
from the surface entirely. A host could see the name it was required to pass and
had no way to write it down, because those types originate in
@nextlyhq/blocks-engine, which is a dependency of this package rather than a
peer.
The root entry now re-exports the engine types the surface is built from, and
the set is CLOSED: an exported type is only as writable as its parts, so a host
handed BlockDefinition could name it and still not write down the supports
object it must pass or the seo() contribution it must return. Everything
reachable from a re-exported type is re-exported too, so annotating any part of
the surface needs no second package.
They live on the root entry rather than /next, whose declarations import the
next and nextly peers a standalone install does not have.
A regression test asserts each is named in an EXPORT STATEMENT of the built
.d.ts, not merely present in the file, and derives what is required from the
declarations themselves — the entries from package.json, the obligation from
the engine's own composition — so the check grows with the API rather than with
someone remembering to extend a list.
nextly's own route types are deliberately not re-exported: it is a peer
dependency, so a host names ContentEntry, RenderContext and the route shapes
from nextly/runtime where they live.
- #646 `743772f` Thanks @mobeenabdullah! - Add the @nextlyhq/builder package, which will hold the visual page-builder editor. It ships no features yet, so there is nothing to install it for: it exists now so the editor arrives under a name that is already reserved and already versioned in lockstep with the rest. It requires React 19, matching the renderer it draws with (@nextlyhq/blocks-react).
- #660 `ba3a72c` Thanks @mobeenabdullah! - Read and write hex colours from the server-safe colour entry point.
- #641 `a2f2080` Thanks @mobeenabdullah! - A content route no longer offers static generation it cannot perform.
createContentRoute and createBlocksPage read access-enforced content, so no
path they serve can be pre-rendered — and they now return no
generateStaticParams at all. Next classifies a route as static BECAUSE that
export exists, and every dynamic marking inside a static render is an error, so
an enforced route that also exported one answered 500 on every path whenever its
collection was empty at build time. Its runtime behaviour depended on whether
the database had rows in it when the build ran.
For public content that should be cached and pre-rendered, call the new
createPublicContentRoute / createPublicBlocksPage. They read trusted and do
return generateStaticParams.
Replaces the overrideAccess option on ContentRouteConfig, which had no
consumers: the posture is now stated by which factory you call.
- #657 `5d6f049` Thanks @mobeenabdullah! - Refresh five transitive dependencies to their patched releases, clearing the six open Dependabot advisories on this repository.
brace-expansion to 5.0.9 (denial of service through unbounded intermediate arrays, bypassing the earlier mitigation), fast-uri to 3.1.5 (host confusion via a backslash authority introducer), js-yaml to 4.3.1 (quadratic CPU consumption resolving !!omap), undici to 7.29.0 (five advisories, the highest being cross-user information disclosure and a parse-time crash on degenerate private cache directives) and dompurify to 3.4.13.
The DOMPurify advisory is the one worth an explicit reachability answer, because two published packages sanitize with it. Reaching it needs IN_PLACE sanitization together with a hook that removes a containing element, and neither sanitizer is that shape: sanitize-svg hooks uponSanitizeAttribute, the embed sanitizer hooks afterSanitizeAttributes, both are attribute-level, and neither sets IN_PLACE. So the bump keeps a dependency on a supported release rather than closing a live hole. Both sanitizer suites pass on 3.4.13.
Each override floor is raised rather than left to resolve upward on its own, because all five were pinned in the lockfile at exactly the last vulnerable patch, and a floor that still admits a vulnerable version lets the next lockfile refresh land back on one.
These are pnpm overrides, so they govern this workspace's builds, CI and local development and do not travel with the published packages. What a consumer of nextly or @nextlyhq/plugin-page-builder resolves for these transitive dependencies is still decided by their own tree.
- #658 `d23b9d7` Thanks @mobeenabdullah! - Report conflicting shortcut-provider options when neither provider attaches a listener.
- #670 `3b88fff` Thanks @mobeenabdullah! - A scoped API key is now judged on its own grants for every Direct API collection and single operation, not just some of them. Previously a key holding only update access could read through operations that forwarded the caller identity without the key scope, because the service fell back to the permissions of the user who issued the key.
- #661 `edf2b04` Thanks @mobeenabdullah! - Stop publishing the rules of a block that draws nothing.
A block can declare that its props make it draw nothing, and core/image with no source and core/embed with no src both do. The stylesheet did not consult that declaration, so every rule compiled for the markup such a node WOULD have drawn was still published — matching no element, and naming whatever it referenced. An image block waiting for its picture announced the URL of a background it never painted.
The declaration now reaches the style compiler, which holds those rules per node rather than emitting them into the main sheet, exactly as it already does for a condition-gated node. A page compiled since carries an entry for each drawless node, and the reader appends only the ones that draw.
What made this worth doing carefully is the direction it must NOT go. Dropping a node from the style input marks the document repaired, and a repaired document with nothing to recompile from has its whole stylesheet withheld. Blanking every rule on a page because one image is waiting for its picture is a far larger regression than the unused bytes it saves, and an unfilled image is an ordinary authoring state rather than the exceptional one the other prune cases describe. So a stored sheet that predates this keeps its node and ships whole; republishing the page compiles the entries and the drop starts working, with nothing to invalidate by hand.
declaresNoMarkup in @nextlyhq/blocks-engine is now the single implementation of the question. SEO derivation had its own copy and now shares this one, so the compiler, the renderer and the derived metadata cannot answer differently about the same node. It fails in the opposite direction to isConditionGated, and deliberately: an unreadable visibility condition must count as gated or hidden content leaks, while a block that throws or answers with a non-boolean must count as drawing or a node that is on the page loses everything derived about it.
Block-type default rules stay in the main sheet, because they come from the block package rather than from the document and a sibling of the same type that does draw still needs them.
- #645 `249649e` Thanks @mobeenabdullah! - nextly: record what email was sent, and what failed
A failed password-reset previously left no durable trace — the adapter threw,
the service returned { success: false }, one line went to the process log,
and the operator learned from the user. Sends are now recorded in
email_deliveries.
The table stores a hash of the recipient rather than the address, and a template slug rather than a rendered subject, so it answers "did this send" and "how many failed" without answering "to whom". Provider failure messages have address-shaped text removed before storage, because an SMTP rejection quotes the recipient back at you.
This is a log, not a queue: nothing drains it, and the retry columns it carries are reserved and inert so that adding a drain later is not a migration on a table already holding history.
The recipient column is a KEYED hash rather than a bare digest. An email address carries too little entropy for a plain SHA-256 to resist an offline dictionary, so anyone holding the table could confirm whether a given person was written to. Keying it with the install secret leaves the support lookup working unchanged while making the column unreadable without that secret. The schema no longer claims the table sits outside identity-erasure obligations, because a keyed hash of an address is pseudonymised data rather than anonymised data.
A send whose bookkeeping fails after the provider accepted the message is no longer reported as a provider failure. Acceptance is recorded the instant the provider answers, so deriving the response cannot turn a delivered message into a full set of failed rows, an after-send action told the send failed, and an auth flow withholding a token.
Provider containment now covers the stages that run with parsed configuration: building an adapter and probing a connection. A parser that derives a credential left both quoting the derived value into a diagnostic that reached the failure log, because the needles were computed from the stored form alone. A parser that renames one is refused outright, for the same reason a parser that shortens one already was.
The provider's own verdict survives a failure in the bookkeeping that follows it. Recording only that the provider answered, and defaulting to success, turned a refusal into a delivery and had an auth flow withhold its undelivered-token fallback for a message that was never sent.
The notice written when a row is kept without its provider reference can no longer change what happened. An installed logger that threw was caught by the recovery's own handler and reported as a retry that failed, for a row sitting in the table.
- #694 `e0e7714` Thanks @mobeenabdullah! - fix(nextly): take the HTTP status from the error code, and record template changes
Eight throw sites restated a status the canonical map already answers, so the number lived in two places and only one would be found by someone changing it. The status now comes from the code alone.
Deleting an email provider nulls the reference on its delivery rows rather than removing them, so the log stays evidence of what was sent. That behaviour now has per-dialect coverage on PostgreSQL and SQLite, where it was previously untested. MySQL still has no such constraint: adding one requires nulling pre-existing dangling references first, which nothing in the schema pipeline does yet.
Email template mutations now reach the activity log. A template decides what a password-reset message says and who it appears to come from, and that change was previously invisible after the fact. Entries carry field NAMES only.
- #626 `fe694de` Thanks @mobeenabdullah! - Email providers are now described by a definition, so a plugin can add one that works everywhere a built-in does.
A contributed provider could previously be registered but never configured: the REST API and the provider service both validated the type against a fixed list of the three built-ins, and defineConfig resolved providers through a hardcoded switch. Registration is now the only thing that decides which types exist.
A provider definition also declares its configuration fields, which values are secret, and how to validate them. Secrets are redacted because the provider says so rather than because a key name looked sensitive, and an invalid configuration is rejected when it is saved instead of when a send later fails.
- #690 `968b7ce` Thanks @mobeenabdullah! - fix(admin): replace one part of the email provider form without resetting the rest
Changing a provider type, or a plugin returning while the form is open, replaced the configuration through a whole-form reset. That makes every current value the form's new baseline, so fields it never meant to touch stop differing from it — and reconciling a refetch keeps only what still differs. A rename typed before either of those happened was silently overwritten by the record's own value.
Each of those now writes only the fields it means to, and a provider type chosen in the picker is kept as the operator's until they save. A descriptor that gains a configuration field while a form is open now initialises it, so a switch no longer draws a position the form does not hold, and a field being edited is left alone.
- #638 `4b2c025` Thanks @mobeenabdullah! - Ask one host list, from both channels a page fetches through.
BlockHostPolicy now carries remotePatterns, in the same shape a Nextly app already declares in next.config for next/image, so copying the entry across just works. A block writes an <img src> or an <iframe src>; a compiled stylesheet writes url(...) into a rule that fires on every page it applies to. Both turn a stored value into a request, and both now ask THIS list rather than each keeping its own, because a policy two surfaces answer differently is not a policy. The style channel asks it through the predicate the engine takes, so the two cannot drift.
core/image and core/embed consult it. For the image, the check is applied to whichever URL was SELECTED rather than to the typed one alone: a URL the resolver returned came out of a media record a person filled in, so it names a host on the same terms the typed prop does, and checking one of the pair leaves the other unbounded.
core/embed consults it, and an unlisted host renders nothing at all rather than an empty frame, for the reason the empty source already renders nothing: a frame with no usable source loads the page inside itself in several browsers. A caller who passed their own mayFetchUrl keeps it, since that is the more specific answer and deriving one here would silently replace it. Absent means unasked rather than allowed-nothing, so a host that configures no list renders exactly as it did before.
Enforcement is per-renderer, and the type says so where someone reading it will find out. The boundary cannot apply this on a block's behalf: it sees the element a block RETURNED, not the URLs the block chose, and an <img src> deep inside returned markup is indistinguishable to it from any other prop. The blocks shipped here consult the list; a block written outside this package is bounded by it only if it asks. A site wanting a hard limit should pair this with a content security policy, which the browser enforces whatever a block does.
core/embed's rendersNothing still answers from its props alone, deliberately. The declaration is read without a render and so has no policy to consult; a URL the policy will refuse is reported there as output and then draws nothing. That direction costs an empty rule in a stylesheet, where the other would claim a drawing block draws nothing.
A stored stylesheet now records which policy compiled it. The artifact is a CACHE of a compile, and a cache is sound only when it is keyed on every input that compile used; the fetch list is such an input, because the same document compiled under two different lists produces two different sheets, one of which may name a host the other refuses. Without that key a sheet written before a policy existed keeps publishing url(https://unlisted…) on a site that has since forbidden it, with the block markup beside it bounded and the stylesheet not.
So PageStyles gains an opaque fetchPolicyId, derived from the patterns themselves rather than assigned, so it changes exactly when they do and there is nothing to remember to invalidate. A reader whose policy does not match the stamp treats the sheet the way it already treats one compiled from a larger tree: recompile when the inputs are there, withhold the CSS when they are not. A sheet that WAS compiled under the current policy is still served from the store, which is why this is a stamp rather than recompiling unconditionally: a site with a policy does not pay a compile per render.
fetchPolicyLabel is public because the write path needs it. A writer that could not compute the same label would stamp nothing, every stored sheet would read as stale, and a site with a policy would recompile for ever.
The type documentation no longer claims every field defaults closed, because two fields now default differently and a host reading the old sentence could omit configuration believing remote fetches were denied. trustedFrameOrigins defaults closed, since the grant it controls lets a frame script the page around it. remotePatterns defaults OPEN, because it arrived after the renderer shipped and defaulting it closed would stop every existing site loading its own images the day it upgraded.
core/image asks the list BEFORE choosing between its two candidates rather than after. Selecting first and filtering after meant a library image the site will not fetch beat a perfectly good typed URL and then took the whole block down with it: the author was left with nothing because of a setting they cannot see, while the fallback they wrote sat unused. Filtering first makes the block render the first candidate it is actually allowed to load, which is what a fallback is for — and it is what the link-preview path does with the same pair, so the page and the preview can no longer choose different images. A record whose URL is refused is dropped WHOLE, since its alt text and intrinsic size describe the asset that was refused.
The page-builder's own guidance is corrected in the same change. It told an integrator that @nextlyhq/blocks-react had no way to bound fetched hosts and to configure the separate page-builder renderer instead. That is now false, and believing it would leave the published page unbounded while the editor was configured — the editor refusing a host the live page then loads.
- #648 `1ddda0f` Thanks @mobeenabdullah! - Page editor: a page holding blocks under a slot that no longer exists now says so and offers to clear them. Such blocks are invisible on the canvas (a block only draws the slots it declares), so until now the page simply refused to save with nothing to select and nothing to delete. A bar above the editor names each affected block and where it sits, and removing one is a per-block choice that undo can reverse. Nothing is discarded automatically.
- #652 `38135e8` Thanks @mobeenabdullah! - Render a very long list instead of losing the block that holds it.
core/list mapped its stored items with no cap. A document's own limits bound node count and depth but never the length of a prop array, so items arrives at whatever length was written — and past the renderer's inspection budget the normalizer refuses the whole output. An accidentally long list therefore cost the reader EVERY item and left a broken-block marker where the list should be, rather than costing only the items past the end.
The items are clamped, and sliced before they are mapped so an oversized array is never walked in full: the work this bounds is the work of reading it, not only of rendering it. The cap sits far above any list a person writes and far below the budget, so nothing hand-authored reaches it and the block still has room for its wrapper.
- #634 `6823b57` Thanks @mobeenabdullah! - Adopt a neutral admin theme. The admin palette is now achromatic in both modes, with every asserted contrast pairing clearing WCAG AA by a margin rather than sitting on the gate. Control boundaries (text inputs, selects, checkboxes, the table search field) move to a visible 3.4:1 edge, active sidebar rows are filled with the surface their ink is declared against, and the dark table header surface no longer carries a hue the rest of the palette dropped.
- #663 `8b136ed` Thanks @mobeenabdullah! - The page builder no longer renders a second
mainelement. A page has one primary landmark, and the editor was adding another inside the admin’s own, which is invalid markup and gives screen readers two competing landmarks to choose between. The canvas pane is now a labelled region, so it is still announced and still reachable by landmark navigation.
- #686 `68145f1` Thanks @mobeenabdullah! - The page builder now names itself in the admin. Its entry in the plugins list and on the dashboard showed the raw package specifier where other plugins show a readable name.
- #600 `80723ec` Thanks @mobeenabdullah! - A preview link that names one entry no longer widens access to the rest of its collection: when the granted entry does not live at the requested path, the published-only fall-through now reads with the caller's own access instead of the trust the draft decision forced on.
- #601 `264bda2` Thanks @mobeenabdullah! - Minting a preview link now authorizes the entry it names, not just the collection: a caller bounded by a row-level rule can no longer mint a working link for a document they cannot read themselves.
- #609 `db83c18` Thanks @mobeenabdullah! - Let a block render nothing without being reported as broken, and test the core primitives through the boundary that wraps them.
A block that deliberately renders nothing, such as an image with no usable source, was replaced by a broken-block diagnostic when its node also carried an anchor id. Rendering nothing is a decision rather than a failure, and the two now have different answers.
Emptiness is judged only from what this renderer can vouch for, which is the part worth reading twice. Two things earn the exemption: the block DECLARES that its props draw nothing, through the rendersNothing contract, which is computed from data this renderer already holds; or the output is a value this renderer OWNS — a primitive React draws as nothing, or an array normalizeRenderable materialised, walked by index exactly as React walks it.
Nothing else. A wrapper the block returned is never opened to see whether it is empty. Its children, a provider's value, an element's key and ref, a Set's iterator and an array's iterator are all author-controlled, and React reads every one of them AGAIN after this check has returned — so an exemption granted on a reading React need not repeat is an exemption that can be wrong. It was wrong in five separate ways, two of which took the whole page rather than one block: an iterable that answered differently on each call, a Set carrying its own iterator, a getter hidden from enumeration, an inherited getter, and a stateful children accessor. The list of properties to probe was never going to close, because every one of them belongs to the author.
The cost is stated plainly: a block returning an empty fragment, an empty Suspense, a hidden Activity or an empty context provider, on a node that also asks for an anchor id, keeps its diagnostic. That block says rendersNothing if it means it, and then the exemption is granted from data rather than from a structure that can change underfoot.
The contract still covers every value React draws as nothing rather than the nullish pair alone. A plugin block written in the ordinary conditional form render: () => enabled && <element /> returns false when disabled, an empty string arrives from a cleared value, and a map over an empty collection arrives as []. A returned Set is materialised before it is read, so it counts too. 0 is deliberately excluded, since React renders it as the character zero: real output with no element to carry the node's fields.
A candidate URL clears BOTH filters before core/image chooses between them, and a media record whose URL either filter refuses is dropped whole. The two refuse different things — the scheme guard refuses a value that could execute, the host list refuses one the site will not fetch from — and this block had been caught twice applying one of them at one position of the resolver/typed-prop pair and not the other. The same pair reaches the link preview, so both run there too, and the preview publishes the URL in the form the guard normalised rather than the form it was handed.
SuspenseList joins the wrapper set the normalizer already accepted as renderable. A type accepted in one list and missing from the other is a wrapper walked to validate its children in one place and reported as output in the other.
The primitives were only ever tested by calling their render functions directly, which is not the path a page takes: the boundary appends the block type class, clones the node fields onto the root, and normalizes the output first. That gap is why this defect and two others reached main.
- #650 `0585842` Thanks @mobeenabdullah! - A public content route no longer expands relations by default.
A trusted read propagates both its trust and a widened lifecycle into
relationship expansion: a populated target is read with access rules bypassed
AND status: "all". At the inherited default of depth: 1, a page in a public
collection could therefore embed a draft or access-restricted row from a
collection appearing nowhere in the route config — and a public route
pre-renders that into a static artifact.
createPublicContentRoute and createPublicBlocksPage now default to
depth: 0. Setting depth explicitly restores expansion, and states that the
populated collections are public too.
- #654 `a3e1849` Thanks @mobeenabdullah! - Re-decide a held shortcut key on every repeat, so a binding whose action changes its own condition stops permitting the browser default.
- #678 `ed5e26e` Thanks @mobeenabdullah! - stop the sidebar content panel from emitting a second main landmark
- #685 `038935d` Thanks @mobeenabdullah! - A Single's schema change now applies its table change and writes its registry row in one place, and the row records the outcome the apply actually reached. Saving a Single that only toggles Internationalization or Draft/Published now records that its companion table was provisioned, and re-saving a Single whose table failed to create can rebuild it and report success instead of staying stuck on "failed" however many times it is retried.
- #635 `9c12a68` Thanks @mobeenabdullah! - Let a site say which hosts its stylesheets may fetch from.
A stylesheet is a fetching surface. background-image: url(...) makes the browser request whatever it names, on every page the rule applies to, and until now the only limit on that was the scheme allowlist. That allowlist answers whether a URL is http(s) rather than javascript:; it has never had anything to say about WHICH host is reached. A value carrying no scheme at all can still name one, because //cdn.example/a.png inherits the page's protocol and nothing else, so a check reading "no scheme, therefore this origin" was wrong about exactly the case that reaches somewhere else. The comment saying so has been corrected, and it is no longer the only thing marking the gap.
StyleCompileContext now takes a mayFetchUrl predicate, forwarded to every URL a compile can emit. A PREDICATE rather than a list of patterns, so the engine holds no matching rules of its own and the caller keeps ONE answer for every channel it owns; which hosts a site trusts belongs to the site, not to the document format. Left undefined, nothing is asked and a compile behaves exactly as it did before, which is what every caller outside a configured site gets. The question is put last, to a value already known to be well formed, so a host rule is never the reason given for a value that was going to be refused anyway.
Coverage is proved rather than asserted. The test walks the catalog for every leaf that can carry a URL, places a refused host at each one and checks none reach the stylesheet, with an allowed host in the SAME position as the control — without it a compiler emitting nothing for that property would pass by writing no CSS at all. Deriving the positions from the catalog is the point: a written list is a snapshot, and the property added next month would not be in it while the suite still reported full coverage.
Two signatures grew a parameter and are now grouped rather than lengthened. validateStyleValues already took six positional arguments and envelopeRules ten, which is past where a call reads by position; a further optional would have sat beside one of a different type with nothing but that type to tell them apart, and a policy lost in a mis-slotted call leaves every URL in the document unasked about. envelopeRules takes a named object instead, so its arity goes down rather than up.
- #693 `c4de051` Thanks @mobeenabdullah! - Serving a page through the new
preparePageForReadno longer publishes stylesheet rules for a block that is missing from the site, so an uninstalled plugin stops leaving its block defaults and named classes behind in the page CSS.
- #612 `3278f13` Thanks @mobeenabdullah! - Add a keyboard shortcut manager to the UI kit: one listener, with precedence that follows the component tree.
Shortcuts registered per component could not decide who owned a key. stopPropagation does not stop other listeners on the same node, so every global handler ran and the winner was whichever component mounted first. Pressing Escape during a drag could cancel the drag and navigate away from the page at the same time.
ShortcutProvider installs the single listener. A nested ShortcutScope outranks the shell around it, and a layer marked blocking also swallows the keys it does not bind, so a drag or a modal can hold the keyboard for as long as it is up. mod resolves to Command on Apple platforms and Control elsewhere, sequences such as g d are supported, and modifier-carrying shortcuts still fire while the user is typing.
- #672 `bb4ebd0` Thanks @mobeenabdullah! - Schema Builder: a unique column that a database cannot index is no longer described two different ways. The rule deciding whether uniqueness is a named index or an inline constraint now lives in one place and is asked by the create path, the add-column path and the desired schema alike, so a reconcile no longer proposes a unique index the server refuses.
- #649 `532ed04` Thanks @mobeenabdullah! - A column declared unique now gets a named unique index instead of an unnamed constraint written into the table itself.
An unnamed constraint is one the database names for you, and on SQLite that name is internal and cannot be referred to. Nothing could describe it afterwards, so the schema Nextly compared against never matched the table, and the only way SQLite could reconcile the two was to rebuild the whole table. Nextly refuses a rebuild it did not ask for, so the entire change was refused with it, including the parts that were only adding things. It also made such a column impossible to remove.
- #637 `891ec3b` Thanks @mobeenabdullah! - A repaired legacy column is now checked for JSON contents before it is converted, and the repair refuses without changing anything when the check fails. A field originally declared as text carries the same legacy column shape as a repeater, so the repair could be offered for prose — failing mid-migration on PostgreSQL, and on MySQL leaving the column renamed but unconverted because MySQL commits schema changes as it makes them.
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/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.54
AlphaReleased 18 packages at 0.0.2-alpha.54 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #581 `8e75d40` Thanks @mobeenabdullah! - Typecheck the block renderer’s own tests, and give block authors a typed defineBlock.
The package excluded test files from tsc, so its tests had never been typechecked. Adding a tests project surfaced eleven errors, nine of which shared one cause: the engine types a slot’s output as unknown because it carries no React types, so a block author could not place it in their own JSX without annotating every render by hand.
@nextlyhq/blocks-react now exports its own defineBlock, which names the context and the slot return type. This is the same service the plugin SDK performs for plugin authors, offered to anyone rendering with this package directly.
- #586 `8e81c4f` Thanks @mobeenabdullah! - Field access rules can ask what the caller is granted, and custom CSS is now a privilege.
A field's access.create / access.read / access.update function now receives
permissions and roles alongside req, so a field can be gated on a permission
rather than only on a role. Collection-level access already received these; field
level did not, so "only these people may write this field" was not expressible.
The grants are resolved once per operation and only when a rule actually runs, so
an entity with no field rules makes no extra lookup. A rule that cannot read the
grants denies rather than opens.
permissions uses the same resource:action spelling collection-level access
uses. Note this differs from the action-resource form the database and the
admin's permission matrix show for the same row.
The page builder's per-page and per-block custom CSS now requires a new
write-builder-custom-css permission. Without it the CSS already on a page stays
visible and keeps applying, but cannot be changed — the field is dropped from the
write rather than the write being rejected, so everything else on the page saves
normally. Grant it to any role that should keep authoring custom CSS.
- #578 `a363c67` Thanks @mobeenabdullah! - Add nine core block primitives.
Heading, text, list, quote, image, button, spacer, divider and embed join the containers already in the library, which is enough to build a real page. Each is a single element with no wrapper, no default padding and no hardcoded colour: styling belongs to the style system.
The accessibility contracts are part of the blocks rather than left to the author. A heading renders the level the author chose rather than one derived from nesting, so the page outline does not change when a block moves. A button renders an anchor when it has a destination and a button when it does not. An image always emits alt text, empty when it is decorative. A quote keeps its attribution outside the quotation. An embed is sandboxed, carries a title, and does not leak the page path to the embedded party.
- #585 `c2ca409` Thanks @mobeenabdullah! - Let each block claim its own DOM id at render, instead of reserving ids in advance.
Which node ends up writing an id is only knowable once a block has run: one that throws, or returns something with no host root, is replaced by a placeholder that emits no id at all. Reserving ids before rendering therefore meant a block that later failed had already taken the id, and the healthy node that wanted it rendered without one in exchange for nothing.
Node ids are still made unique before rendering. Those are React keys, and a duplicate makes React reuse one block’s instance for another, which is a wrong page rather than a missing anchor.
- #394 `2892263` Thanks @faisal-rx! - Fix localized entities breaking schema applies and singles reads: SQLite/MySQL schema syncs no longer fail once a
_localestable exists, singles created in another dev worker resolve without a restart, enabling Internationalization without alocalizationconfig is rejected with a clear error (and the builder switch explains it), and adding thelocalizationblock to nextly.config now takes effect without a manual restart in dev.
Collection and single tables created on SQLite or MySQL from now on also get the indexes Postgres and the Schema Builder already created for them, including the unique index on slug. Creating an entry with an explicit slug that another entry already uses now fails with a duplicate error on those dialects instead of being accepted silently. Tables created before this release keep the shape they were created with and are not backfilled, so an existing collection continues to allow duplicate slugs until its table is rebuilt.
- #595 `8b7ce78` Thanks @mobeenabdullah! - Report the class library slot that was dropped when the same class is listed twice. Only the first of two entries claiming one id or one name is written, and the warning explaining that named the entry rather than the slot — so a library built by reference, with one object in two slots, reported nothing at all and left an editor with no position to repair.
- #584 `f7229c8` Thanks @mobeenabdullah! - Refuse a plugin permission that collides with one a collection or single already owns, including for Schema Builder entities the config cannot see and for declarations that differ only in letter case. Honouring such a declaration hands the plugin a permission the role presets grant to editors, so the collection quietly stops being editable by them. An application already running such a plugin can set NEXTLY_ALLOW_PLUGIN_PERMISSION_OVERRIDE=1 to keep booting with a warning while it is fixed.
- #576 `8ff9c59` Thanks @mobeenabdullah! - Resolve a scoped preview link by the entry it names.
A preview grant that names an entry is now read by that id and confirmed to live at the requested path, instead of resolving the path by slug and comparing ids afterwards. A slug is not unique, so the old order could find a different document, reject it, and fall back to published, showing an editor live content at a link they were given for a draft.
When the named entry is gone or lives at another path, the request holds no draft authorization for that path and resolves published-only, so the widened lifecycle scope cannot surface a row the grant never named.
- #583 `e7e51d9` Thanks @mobeenabdullah! - Add the admin side of shareable preview links.
A previewLinkApi service and a usePreviewLink hook mint a link for one entry and put it on the clipboard. This is distinct from the Preview button beside it: Preview opens the entry using the editor’s own session and can include unsaved changes, while a preview LINK goes to someone with no session at all, so it carries its own signed authorization and shows only what was saved.
The link is minted per click rather than cached, because it carries an expiry and a cached value would be handed out after it stopped working. When the browser refuses clipboard access, which happens on an insecure origin, the link is shown rather than a copy being claimed that never happened.
- #580 `fdefbe2` Thanks @mobeenabdullah! - Add endpoints for minting and revoking preview links.
POST /api/nextly/preview-links mints a link scoped to one entry, gated on update for that collection rather than on publish: someone who can edit an entry already sees its draft, so sharing a link to it grants nothing new, while requiring publish would break the workflow where an editor who cannot publish shows a draft to a reviewer.
POST /api/nextly/preview-links/revoke invalidates every link ever issued, including sessions already in flight. It is gated on manage settings, because the generation it moves is site-wide.
The mint returns a token rather than a URL, since where the preview route is mounted is the application’s decision.
- #579 `5bf444e` Thanks @mobeenabdullah! - Stop shipping CSS compiled for blocks that render a placeholder.
A node that resolves to a placeholder emits only a hidden marker, so every rule compiled for the markup it would have rendered matches nothing and ships anyway, carrying whatever those rules referenced. The stylesheet is now compiled from a tree with those nodes removed, while the render keeps them so their placeholders still appear.
- #594 `5a0c8f6` Thanks @mobeenabdullah! - Add the stylesheet a whole site shares, compiled once from its design tokens, self-hosted fonts, named classes and block-type defaults, and named by a hash of the bytes it produced. Every page of a site repeats those rules today; a shared sheet is written once and cached until something in it actually changes. A token stored without any values is now reported and skipped rather than ending the compile, which would otherwise have taken down every page on the site.
- `a323af5` Thanks @mobeenabdullah! - Hold a conditionally-shown block's own styles out of the page stylesheet, returned separately so a reader can add back only the blocks it kept. A page's CSS is compiled when the document is saved and a condition is decided when the page is read, so one stylesheet otherwise carries rules — and any image URLs inside them — for blocks the reader removes. A page with no conditional blocks compiles exactly as before.
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/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.52
AlphaReleased 18 packages at 0.0.2-alpha.52 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #532 `4902ef4` Thanks @mobeenabdullah! - Give a column added by an edit the constraints and indexes creating the table would have attached: a one-to-one is unique, a relationship is indexed, and a requested index exists. Adding a required relationship to a collection that already has entries is now refused with the steps that work instead of emitting invalid SQL, and removing a relationship drops its foreign key first on MySQL and is refused on SQLite, which cannot drop one without rebuilding the table.
- #526 `8bdf575` Thanks @mobeenabdullah! - Erase a deleted account's request identifiers from the auth log.
Deleting a user already removed their name and email from the activity log while keeping the record itself. The auth log identifies a person a second way — by the address they connected from and the client they used — and those survived untouched. They are now erased on the same deletion, stamped with when, while the event kind, the actor and target references and the timestamp stay: that is the security fact a retained trail exists for.
Erasure is keyed on the actor. A row naming someone as the TARGET carries the
address of whoever acted on them, so erasing by target would scrub a different
person's data and leave the subject's own in place. Events recorded without an
actor — a failed login, a rejected CSRF — are out of reach by design, since they
are written unattributed precisely so a failure cannot reveal which account was
reached; nothing links them to a person, so no deletion can find them. This
table is pruned on audit.retention.authMaxAgeMs — 180 days by default — so a
window is what bounds them. A window is a weaker guarantee than an erasure,
which is why the metadata projection below is default-deny: what never enters is
the only thing certain not to persist.
Whether each table can be erased is now decided per table. A database can carry one and not the other, and answering for the pair would let a missing auth log suppress the activity erasure, leaving behind the names and emails the deletion exists to remove.
Identifiers are also kept out of the auth log's metadata in the first place. A
NextlyError's logContext is written for operator triage, and a failed login
puts the attempted email address there; the auth handlers copied that context
into the stored event wholesale. A failure is recorded with no actor precisely
so it cannot reveal which account was reached, so nothing links such a row to a
person and the deletion that erases their other rows can never find it — the
identifier has to not be stored rather than be erased later. Only an allowlisted
set of diagnostic keys is now copied, default-deny, so a key added for logging
cannot silently become a field of the audit trail.
Naming a key is not enough on its own, because none of the values are ours to
begin with. An AuthStrategy is application code and chooses its own failure
reason; an error's code accepts any string, and the two diagnostic codes are
copied straight from it. Each retained value is now checked against a vocabulary
this package controls — a reason it produces, or a code the canonical table
defines — and anything else is dropped. The value still reaches the operator log;
what it no longer does is enter a trail nothing can associate with a subject.
The reasons are named in one place that the handlers emitting them now compile
against, so a new reason is a type error until it is listed rather than being
discarded without a diagnostic. Three that the initial-password exchange already
emitted were being discarded that way, leaving pending-token-wrong-challenge, a
stale must-change state, and a missing user indistinguishable from each other in
the trail. All three are recorded again.
Upgrading: rows written before this change are not covered. The handlers
previously stored the whole error context, so existing unattributed
login-failed rows can already hold an attempted email address or a user id.
Deletion is keyed on the actor and those rows have none, so nothing reaches them
— the projection applies only to failures recorded from now on.
Accounts deleted BEFORE this change are not covered either, for the opposite
reason: their attributed rows still hold the address and client they connected
from, and the erasure added here runs during a deletion — it can never run for
an account that is already gone. actor_user_id carries no foreign key, so
those rows survive as orphans pointing at nothing.
Scrub both once, before or after upgrading:
-- Rows recorded without an actor: the context the handlers used to store -- wholesale, which may name an attempted address. UPDATE audit_log SET metadata = NULL
-- Rows attributed to accounts that no longer exist: their request identifiers,
-- which the deletion that removed them never erased.
UPDATE audit_log SET ip_address = NULL, user_agent = NULL
WHERE actor_user_id IS NOT NULL
AND actor_user_id NOT IN (SELECT id FROM users);
`
The first discards the diagnostic codes on those rows along with the
identifiers. The second leaves actor_user_id in place — the trail should still
say that the same someone did these things, only not who they were. The event,
its outcome and its timestamp are columns, and neither statement touches them:
that is the security fact the trail exists for.
Upgrading, PostgreSQL and MySQL: one required action. If you hardened
audit_log by revoking UPDATE — the posture this package previously documented —
grant it back for the three columns an erasure touches, or deleting a user will
fail and roll back:
GRANT UPDATE (ip_address, user_agent, identity_erased_at) ON audit_log TO app_role; GRANT DELETE ON audit_log TO app_role;
Two duties need those grants. Erasing the address and client a deleted account
connected from is an UPDATE, and it runs inside the deletion's transaction, so a
blanket revoke blocks account deletion outright. Pruning rows past their window
is a DELETE, and a role without it fails every pass silently — retention must
never fail the request that offered it — so the table grows unbounded while the
setting reads as enforced. Revoke DELETE only together with
audit: { retention: { authMaxAgeMs: false } }, so the configuration says what
the privileges actually do. Every other column stays immutable. Deployments that
never restricted these grants, and all SQLite deployments, need no action.
***
Prune the activity and auth trails on a schedule.
This deletes data the first time it runs. Set the windows before you deploy if you need longer ones.
Neither trail has ever actually been pruned. activity_log has claimed a 90-day
policy in its own schema comment since it was introduced, but the cleanup that
comment named was never called from anywhere — and could not have worked if it
had been, because it referenced a column that does not resolve and its failure
would have been swallowed. Installs are therefore carrying every row ever
written, while the schema said otherwise. audit_log never promised anything
and grew unbounded too.
Both are pruned now, and the first pass removes everything already past its
window:
- activity_log — content activity, who changed what — 90 days
- audit_log — sign-ins, password changes, role grants — 180 days
90 for content activity is what the comparable self-hosted CMSes default to, and 180 for auth events is what GitHub and Atlassian Cloud retain: security questions are asked later than editorial ones, because a compromise is usually noticed well after the sign-in that caused it.
To keep more, configure it before upgrading:
export default defineConfig({
audit: {
retention: {
activityMaxAgeMs: 365 * 24 * 60 * 60 * 1000,
authMaxAgeMs: false, // keep auth history forever
},
},
});Each window is independent, so bounding the high-volume feed while keeping
security history indefinitely is one setting rather than a compromise.
audit: { retention: false } keeps everything, as today.
Passes run opportunistically off content writes, at most one per interval,
batched, and never fail the write that offered them. Batching matters on the
first run in particular: an install that has never pruned faces every row it has
ever written, and an unbounded DELETE there would take a long lock on the
largest table at the worst possible moment.
Scheduling is now shared rather than duplicated. The gate, interval and never-throw wrapper that webhook retention already used are a general mechanism, so audit retention registers a pass with it instead of introducing a second one. Each pass is gated on its own key: a single shared marker would let whichever pass ran first consume the interval for the others, and the busier domain would starve the rest indefinitely.
- #539 `49d44ae` Thanks @mobeenabdullah! - feat(blocks-react): add the React renderer package boundary
Adds @nextlyhq/blocks-react, the React/RSC renderer for Nextly block
documents. This change lands the package and its layering guarantees; the
renderer itself follows.
The root entry imports no next/*, no admin code and no CMS runtime, so a
document can be rendered from a plain React app, a test or a script. Everything
Next-coupled lives at the @nextlyhq/blocks-react/next subpath, so importing
the renderer never pulls Next into a consumer's module graph. Both rules are
enforced by an allowlist-based import test rather than by convention.
PageContext and BlocksDataProvider are also introduced: the seam through
which data, media URLs and entry paths reach a block, so blocks never reach for
a database directly.
- #536 `d53bc9f` Thanks @mobeenabdullah! - A text column keeps the width the builder that created it gave it.
A text field that states no width does not have one right answer. Three builders create tables and
they read a width from different keys and read silence differently: the Schema Builder's collection
creator bounds on a short variant, its field-group creator bounds on a declared maxLength and
never looks at a variant, and code-first tables were built with a bounded default. Which rule
applies is a fact about the entity, not about the field.
Describing a column without that fact meant guessing, and each place that guessed got it wrong for at least one builder. On MySQL a field group's short text field was described as unbounded when it had been created bounded, so a schema preview reported a type change on a column nobody had touched, and applying it would have rewritten the column. The same guess reached the localization companion tables, Single identity seeding, and the path that adds a column to a table that already exists.
The builder is now named wherever a column shape becomes DDL, so the width follows the table rather than being re-derived from the field. Paths that only look a table up to run a query are unaffected: a declared width is enforced by the database, not by the ORM.
- #514 `bffeac4` Thanks @mobeenabdullah! - Custom CSS in the page builder can no longer load anything from another origin.
- A
url()carrying a scheme or a host is refused, and the editor says which - declaration went and why, with a remedy that works whichever storage adapter the
- media library uses.
This closes a way of reading data off the page. A selector that matches only on
a prefix, paired with a URL that fires a request when it matches, spells a value
out one character at a time — input[value^="a"] { background: url(...) },
repeated. Custom CSS is the only surface where an author writes both halves, so that is
where the ban is absolute.
Banning it in custom CSS alone would not have closed the channel, because the two halves need not be written in the same place. A block's background image is compiled into the same stylesheet, so a remote image there plus a custom selector that suppresses it conditionally still leaks by the request's ABSENCE, with no URL in the custom CSS to refuse.
So a block's images are restricted the same way, and a site declares the hosts
it loads from. A relative path such as /media/a.png needs nothing; anything
carrying a host needs an entry, INCLUDING an absolute URL on your own domain,
exactly as next/image already requires:
<PageRenderer
document={doc}
remotePatterns={[
{ protocol: "https", hostname: "cdn.example.com", pathname: "/img/**" },
]}
/>The policy covers every value a block emits, not the properties someone
remembered can fetch: filter: url(…) is a request too, and so is
filter: var(--missing, url(…)), whose URL lives in a fallback the parser
leaves as raw text. A protocol-relative //host/a.png is refused rather than
resolved against a guess, since the document's protocol is not knowable when the
stylesheet is compiled.
BREAKING, and wider than images: every resource a block loads on its own is now
refused until its host is declared. On upgrade, add the hosts below to
remotePatterns or the content stops rendering.
| block | what stops | host to declare |
| --------------------------------------------- | --------------------- | --------------------------------------- |
| core/image | the image | wherever your media is served from |
| core/cover, core/slides, flip cards | the background | same |
| core/gallery, the carousels, core/hotspot | the images | same |
| core/video | the source and poster | your media host |
| core/lottie | the animation | the animation's CDN |
| core/embed (URL mode) | the iframe | e.g. www.youtube.com |
| core/map | the iframe | www.google.com, or your own tile host |
This includes absolute URLs pointing at your own site: nothing in the compiler
knows what your host is, so https://your-site.com/a.png needs an entry while
/a.png needs none — the same line next/image draws. If your media library
stores absolute URLs, which the cloud storage adapters do, declare your own host.
A custom block registered from outside this package applies the policy itself:
its render receives remotePatterns, and mediaUrl / cssMediaUrl are
exported for it. The renderer cannot inspect the element a block returns, so a
block that writes a URL into an src or an inline background without asking
reaches whatever host it names. The shape is Next.js's images.remotePatterns, so an entry can
be copied straight across from next.config, and the posture matches
next/image — nothing off-origin unless you said so. Matching uses picomatch
with the same options next/image uses, rather than an approximation of it, so
hostname and pathname globs mean exactly what they already mean in your
next.config. search is honoured too.
Everything the sanitizer removes is now reported rather than dropped silently, including at-rules it does not support. A rule that disappears with nothing on screen to explain it reads as a bug in the builder, and the author's own source still contains the line that did not survive.
CSS the sanitizer cannot read through — a rule nested deeper than it follows, or a fragment it cannot parse — is still removed, but it is now reported as unchecked rather than as a remote URL. It previously named the whole rule as the offending address, which sent authors looking for a host their stylesheet never mentioned. The depth it follows also rose well past real CSS: the old limit refused valid stylesheets at five levels of nesting, which ordinary compiled CSS reaches.
BREAKING, for anyone calling the sanitizer directly: sanitizeCustomCss and
sanitizeBlockCss return { css, warnings } rather than a string. They are
re-exported from the package root, so this is a visible change even though the
page builder itself is the only expected caller. Read .css where you read the
result before.
Also on that surface: CssWarning["code"] gains "unchecked", which a switch
over the union has to handle, and CSS that fails to parse outright now reports
"unchecked" where it reported "unsafe-value". MAX_RULE_NESTING and
MAX_VALUE_NESTING are exported alongside them.
- #528 `938898d` Thanks @mobeenabdullah! -
create-nextly-apprecognises the development-diagnostics setting however an existing.env - spells it, and no longer mistakes a different variable for it.
A substring test treated NEXTLY_DEV_DIAGNOSTICS_BACKUP=1 as the setting already being present,
so such a project was skipped and never told the real one exists. The check now matches an
assignment at the start of a line, including the commented form and the export KEY=value form
dotenv accepts so a file can also be sourced by a shell.
The whitespace in that match is confined to the current line. Allowing it to cross newlines made
the scan backtrack across the blank lines an .env is full of, which is quadratic on the common
case of a file that does not contain the key at all.
- #537 `a281098` Thanks @mobeenabdullah! - The Direct API types a row the way the process sees it: a timestamp is the Date the driver decoded, not the formatted string a REST response carries. Codegen records which fields a collection or single stores in a timestamp column, and the wire types are unchanged.
A write returned an undecoded row on the raw-SQL paths, so a created row carried epoch numbers on SQLite where a fetched one carried Dates. Every raw-SQL row now decodes the way a read does.
The media services name the error code they mean rather than leaving the boundary to infer one from a status, so a folder-name clash keeps saying "already exists" instead of "reload".
- #529 `17be415` Thanks @mobeenabdullah! -
SubmissionDocument.statusnow includes"spam", and gainsspamReason.
The stored field has always offered spam, the admin has a Spam tab and filters its other views
with not_equals: "spam", the notification hook skips it, and marking something "Not spam" moves
it back to new. Only the TypeScript type disagreed, so it described a shape the database cannot
produce — narrowing on status could not see the case that actually reaches the UI.
The conversions from a stored row to this plugin's document types now live in one module rather than at six call sites. They are still unchecked assertions, which the module says plainly: the services layer answers with a loose row and TypeScript has no overlap to verify. Nothing about runtime behaviour changes; the unchecked step is now in one place a reviewer can find.
- #521 `d58130a` Thanks @mobeenabdullah! - Keep the Schema Builder's DDL generator, the column descriptor and the write path agreeing on which
- fields are junction-backed. A field carrying
relationType: "manyToMany"was treated as - junction-backed by the descriptor whatever its type, while the generator emitted a junction table
- only for a
relationship. Anuploaddeclared many-to-many therefore got a parent column that the - runtime schema and the schema diff did not know about, so the diff proposed dropping it on every
- apply.
Junction storage is a relationship feature, because that is the only shape the read and write
paths implement, so an upload carrying that option keeps its own column and is unaffected: a
single target is a foreign key, hasMany or an array of targets a JSON array of ids. A
relationship many-to-many is unchanged — no parent column, one junction table.
- #519 `3a1b43b` Thanks @mobeenabdullah! - One table now decides what an HTTP status means when a failure names no error code.
Three tables used to, and they disagreed. The same code-less 401 reached a Direct API caller as
AUTH_REQUIRED and a REST caller as INTERNAL_ERROR; a code-less 429 lost its rate-limit
identity entirely, and with it the Retry-After a client needs to back off correctly. The media
service kept a third table that read 409 as DUPLICATE and 422 as BUSINESS_RULE_VIOLATION.
A code-less failure now resolves through one shared table for 400, 401, 403, 404, 409, 413, 415, 422, 429, 502 and 503, and anything unrecognised stays an internal error. The producer's own status is preserved rather than rounded to the code's canonical one.
The table is a fallback, not a translation. A status is coarser than a code: 409 covers both
"that name is taken" and "someone else edited this", which need opposite advice. A service that
knows which one it means sets code and is believed. MediaResponse, DeleteMediaResponse,
FolderContentsResponse and the folder bulk-delete result can carry a code for exactly this
reason, and creating a folder whose name is taken now says so through DUPLICATE rather than
relying on a boundary to guess.
A code-less failure never puts its own message on the wire. Those envelopes come from legacy converters that may store a raw exception's text, so the caller gets the generic sentence for the derived code and the detail stays in the operator log. A failure that names a code keeps its own message, which the producer authored to be read.
Behaviour changes worth checking if you read error bodies directly: a code-less 401 answers
AUTH_REQUIRED instead of INTERNAL_ERROR; a code-less 429 answers RATE_LIMITED; a code-less
422 answers INVALID_INPUT; and through the Direct API a code-less failure's message is now the
generic sentence rather than the service's raw text.
- #538 `4f009ae` Thanks @mobeenabdullah! - A plugin can now hand its own configuration to its own admin components.
A plugin's factory runs on the server, where the host builds its config; its
admin components run in the browser. Nothing carried a value between the two, so
a plugin could ship behaviour it had no way to configure. contributes.admin.clientConfig
travels with the rest of the admin metadata, and usePluginClientConfig reads it
back. It is PUBLIC — /api/admin-meta needs no authentication, so it reaches
anonymous callers and must hold nothing secret — and the serializer refuses
anything that will not survive the trip rather than delivering a mangled copy.
The page builder uses it for remotePatterns. The editor canvas previously
enforced an empty allowlist while the published page enforced the host's, so it
hid images the live page shows.
Pass the SAME value to both pageBuilder({ remotePatterns }) and
PageRenderer. They are separate assignments: the plugin option configures the
editor, and PageRenderer reads only its own prop. Setting just one is what
produces a mismatch, in whichever direction you set it — a shared constant in
the host is the way to keep them equal.
- #523 `f835ca9` Thanks @mobeenabdullah! - New apps document the development error-diagnostics opt-in.
An error response is deliberately generic — a code, a public message and a request id — and withholds the log context and the underlying cause so a response cannot disclose driver output, table names or internal paths. That is right for a deployed app and unhelpful while building, where the withheld part is exactly what you need.
NEXTLY_DEV_DIAGNOSTICS=1 adds a _devDiagnostics field carrying that detail. It existed
already, and nothing mentioned it, so an author hitting an error had no reason to suspect a flag
would have named the cause. create-nextly-app now writes it into .env and .env.example
commented out, with an explanation, and docs/configuration/environment.mdx describes it with
a worked example.
It is documented rather than enabled: the flag is the second of two independent signals, and the
second exists because NODE_ENV is a runtime value a deployment can carry by mistake. A default
shipped in .env would be true in exactly that case — the one it guards against.
Installing into an existing project that already has a configured .env adds the note too, keyed
on its own absence rather than on DATABASE_URL.
- #541 `72c894b` Thanks @mobeenabdullah! - A timestamp is stored the same way whatever the server timezone is. The raw-SQL write paths bound a JS Date directly, so the driver serialized it with the local offset and a column declared without a time zone kept the local wall clock, while every read interpreted that wall clock as UTC. A row written and read back on a server five hours ahead of UTC came back five hours late. Values are now encoded through the column the same way a Drizzle query encodes them, on PostgreSQL and MySQL; SQLite was unaffected, storing unix seconds, which carry no zone.
Rows written before this on a server that was not on UTC keep the wall clock they were given, so a table can hold both conventions until those rows are corrected. Deployments running UTC, which includes every default container image, are unaffected either way.
- #543 `9ccff93` Thanks @mobeenabdullah! - Add two editor-shell primitives to the UI kit: a right-click context menu, and resizable panel regions whose split can be dragged or moved from the keyboard. Both are experimental until a first-party plugin uses them.
- #525 `6c77f8f` Thanks @mobeenabdullah! -
@nextlyhq/ui's release tags now reach the published types. Every export in the - barrel carried
@publicor@experimental, and none of it survived the build: - the declaration bundler flattens each re-export into one
export { … }clause - and drops the doc comment attached to the export statement, so an editor
- hovering
badgeVariantswas told nothing about its stability. The tags live on - the declarations now, where the bundler keeps them, and 229 of them reach
-
dist/index.d.tswhere there were none.
toast and ToasterProps are re-exported from sonner, so their declarations
are not ours to annotate; they stay tagged in the barrel only. cn and
uiPreset, which ship from their own subpaths, carry @experimental now as
STABILITY.md already classified them.
Twenty prop types were also promoted to @public, which is a widening rather
than a change of intent: STABILITY.md already guaranteed that a prop type
carries the same stability as its component, and every one of these belonged to
a public component while advertising @experimental — so the published type
withdrew what the component promised, and a plugin could not wrap Tabs or
Dialog without depending on something labelled unstable. The rule is now
enforced by a test rather than written down.
Modal scrims are a theme token. Six components wrote the backdrop inline as
bg-black/80, identical in light and dark and at four different strengths, so
it could be neither themed nor white-labelled and was invisible to every token
check the package has. --nx-overlay (with --nx-overlay-soft for a scrim over
content rather than the page, and --nx-overlay-strong for one that carries
text directly — a full-screen state screen, an image lightbox and its caption,
where the muted detail line rather than the heading decides the strength: over
a white page text-white/60 is 2.81:1 on the see-through scrim and 5.66:1 on
the strong one) is defined for both modes and used everywhere,
with bg-overlay / bg-overlay-soft utilities in the v4 theme AND in
@nextlyhq/ui/tailwind-preset, so the documented Tailwind v3 path generates
them too. Dialogs, sheets and the command palette now share one backdrop
strength rather than three.
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/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.51
AlphaReleased 17 packages at 0.0.2-alpha.51 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #495 `90dbe11` Thanks @mobeenabdullah! - Deleting a user no longer deletes what they did. Activity-log entries carried a cascading
- foreign key to the account that produced them, so removing a user destroyed their entire audit
- trail. The entries now outlive the account, and the account holder name and email are erased from
- them at deletion time instead, leaving the record of what happened intact and attributed to an
- opaque id. The dashboard activity feed renders those entries as a deleted actor rather than a
- blank one.
- #520 `ab607c3` Thanks @mobeenabdullah! - The admin panel's stylesheet no longer publishes names into the page that hosts
- it. Its animation names and Tailwind's internal
--tw-*custom properties were - resolved for the whole document regardless of the scoping on its selectors, so
- a host defining
spin,fade-inor the same--tw-*registrations shared them - with the admin and the later stylesheet won. Both are namespaced now, and the
- build fails if either escapes again.
@nextlyhq/ui's Tailwind preset keeps its named-plus-default export shape,
which the build warns about. That shape is deliberate and now says so at the
build config as well as beside the code: a preset is consumed as a value, so
require() has to return it, and silencing the warning would change it back.
The field-UI kit gains ConditionRow (@experimental), exported from
@nextlyhq/plugin-sdk/admin alongside operatorsForType and
operatorTakesValue. It edits one condition as source / operator / value,
choosing the operators and the value editor from the source field's type, and a
source carrying an option list is compared against a dropdown of exactly those
rather than free text. It owns the row and not the container, so a surface keeps
its own chrome; pass operatorsFor to narrow the offered operators to the ones
your runtime can evaluate.
Both first-party condition editors now compose it. The schema builder's gains
nothing an author will notice beyond the value dropdown; the form builder's
gains type-aware comparisons, a dropdown for choice fields, and typed number and
date inputs. Stored shapes are unchanged in both, including the form builder's
comparison key and its seven-comparison vocabulary.
- #493 `d8d5bfe` Thanks @mobeenabdullah! - Keep the durable first-publication marker on every shape the entry editor uses, and let the
- editor trust it. A published entry that was unpublished and then reloaded no longer offers its
- slug back to the title generator, so republishing lands at the address the links already point
- at. The marker is consulted only for a slug shared by every language, because it records that a
- document was public somewhere rather than in one particular language.
The marker also survives editing: a document with a pending working draft now reports it on the save response and on the draft read, as a date rather than a string, matching an ordinary read.
- #515 `19efb3a` Thanks @mobeenabdullah! - The admin now reports a save whose follow-up actions failed, instead of showing it as a clean save.
A post-commit hook (afterCreate / afterUpdate / afterDelete) runs once the row is already
durable, so a handler failing there cannot un-save it. The server has always answered success and
carried the failure alongside as warnings, but the admin's entry clients returned only item and
discarded that array, so a search index that was not reindexed, a webhook that was not delivered or
a cache that was not purged looked identical to a clean write.
Creating, updating or deleting an entry now shows "Entry updated successfully, but 2 follow-up actions failed" with the failures behind a disclosure. It stays a success toast, never an error: the row IS saved, and reporting a failure would invite the editor to repeat a write that already took effect.
entryApi.create, entryApi.update and entryApi.delete now resolve to { item, warnings? }
rather than the entry alone. The onSuccess callbacks on useCreateEntry, useUpdateEntry and
useDeleteEntry still receive the entry, so callers of those hooks are unaffected.
- #504 `e7a675f` Thanks @mobeenabdullah! - Schema Builder tables keep the text column width they had. Creating a field group or single routed its columns through the shared descriptor, which read a text field with no stated width as bounded where the previous generator read it as unbounded, so on MySQL a new text column held 255 characters instead of 65 535.
A text field that limits its length now gets a column at exactly that limit, on every path that can build one: a field limited to 400 characters no longer lands in a column that rejects what its own validation accepts. The limit is the field's validation maximum, which is the one the Schema Builder has always sized a bounded column from. Localized companion migrations, Single identity seeding, and columns added to an existing table all recognise the bounded text column, so a freshly generated migration applies, a new Single keeps its seeded title and slug, and a column added at boot is not reported as changed on the next preview.
A field whose type belongs to a plugin that is not loaded also keeps the unbounded column it was built with, instead of being reported as a narrowing on a table nothing has touched.
A field group's text field that declares a maximum length keeps the bounded column it was created with. Its width is declared under a different key from a collection's, which the schema comparison did not read, so on PostgreSQL such a field was reported as a type change on a column that had not changed.
- #509 `c686245` Thanks @mobeenabdullah! - Behaviour change. A code-first collection or single that declares a field named
id, -
createdAt,created_at,updatedAtorupdated_atis now refused when the config is read, - instead of failing later during schema application. Any casing that resolves to one of those
- columns is refused too, so
CreatedAtis caught alongsidecreatedAt.
Such a collection could never have worked: the field is emitted alongside the injected column and the database rejects a table that declares the same column twice. The error now names the column it collides with, and arrives where the name is chosen.
title, slug and status are unaffected and remain declarable — the first two step aside for
an author's own field, and a status field is taken up by the draft/publish lifecycle.
- #507 `f348a0f` Thanks @mobeenabdullah! - Resolve a field name to its database column the same way everywhere. A Schema Builder collection
- created a field whose name began with a capital under an extra leading underscore, while the
- runtime schema and the schema diff addressed it without one — so the table and every read of it
- disagreed, and the diff reported the column missing on every apply.
Every decision about which column a field occupies now asks the same question of the same
conversion: which system column an author's field replaces, whether two names collide, which system
fields a config factory injects, and which columns an ALTER may touch. Two fields whose names reach
one column (such as foo_bar and FooBar) are now reported where the names are chosen rather than
failing during schema application, and editing a many-to-many field's index or flags no longer emits
statements against a column it never had.
Field types that store their values in their own tables, such as a component or a many-to-many relationship, are consistently treated as occupying no column: they neither collide with each other nor suppress a system column that still has to be injected beside them.
**Two configurations that were previously accepted are now refused at startup, with an error naming
the fix.** A field may replace the system title or slug column only under that column's own
name: title still works and is unchanged, while Title is refused, because it reaches the same
column while remaining a separate identity in every payload — a create carrying Title gained a
second generated title and the generated value overwrote the author's. And a field whose name
reaches a column the Draft/Published lifecycle owns is refused while that lifecycle is enabled; such
a collection could never have been created, since the column was declared twice. With the lifecycle
off, status remains an ordinary field name.
Emitted SQL is unchanged for every field name the Schema Builder accepts.
- #496 `387061e` Thanks @mobeenabdullah! - Reject a field named
id,createdAtorupdatedAtin a Field Group (component), through both - the visual builder and
defineFieldGroup. A component keeps its values in a table of its own - carrying those columns, so such a field is emitted into the same
CREATE TABLEas the injected one - and the database refuses the statement. The name is now refused where it is chosen, with a message
- saying which system column it collides with.
Field groups that already declare such a field could never have had a working table, since creating it fails; they will now be reported at configuration time instead of during schema application.
- #505 `e7316d8` Thanks @mobeenabdullah! - A core schema change now reaches a database that already holds content. Adding a column to
- one of Nextly own tables, or changing a constraint on one, was silently skipped on SQLite and
- MySQL whenever any content table existed, while nextly migrate still reported success. The
- reconcile now runs a second pass after a degraded one: with nothing left to create, the schema
- differ has no ambiguity to resolve and emits the alterations it previously abandoned.
- #510 `781fa81` Thanks @mobeenabdullah! - Custom CSS in the page builder can no longer end the
<style>element it is - rendered into. A value written with a CSS escape, such as
-
content: "\3c /style>", contains no markup as authored but was decoded into - markup when the stylesheet was serialized, and on a server-rendered page the
- browser then parsed whatever followed it as HTML. Those sequences are now
- escaped on the way out, so they still mean the same thing to CSS and nothing to
- the HTML parser.
Custom CSS also keeps its meaning inside :not(), :is(), :where() and
:has(). Scoping used to rewrite the selectors held by those, so
.a:has(> .b) silently became "has a .b anywhere under the page root".
- #508 `444bd26` Thanks @mobeenabdullah! - An error thrown by a Direct API call now chains the failure it actually came from. The public
- result shape drops the driver error and the identifiers the thrower attached, and the boundary
- rebuilt from what survived, so every unexpected failure arrived looking alike. The original is
- carried alongside the envelope and chained as the rebuilt error cause.
- #490 `a2e92ae` Thanks @mobeenabdullah! - Blocks now receive a render context, so a block that reads content is an
- ordinary async component rather than something the API had no way to express.
- A slot is now something a block draws rather than something it receives already
- drawn:
renderSlot(name, ctx?)replaces the map of rendered children, so a - repeater can draw its template once per entry with that entry's values, and a
- block that hides a panel no longer pays to render it.
A block's supports is checked against the catalog while it is being written
instead of at boot, and a plugin that registers its own support adds it to that
check by augmenting BlockSupportKeys in @nextlyhq/plugin-sdk/blocks. A key
lists the sub-flags it recognises as a union of strings, and declares either
never or true when it is all-or-nothing; both are read the same way, and a
sub-flag the key does not declare is refused where it is written. The
types a block definition asks for are all reachable from that same subpath, so
writing a block no longer means importing the engine directly. Renderers now
describe what they provide once by augmenting BlockRenderContext, so ctx is
typed without every block naming a context type of its own.
Breaking, in an experimental package:
- BlockSupportValue is no longer exported from @nextlyhq/plugin-sdk/blocks.
It is the shape the registry stores from every source, so as authoring
vocabulary it accepted a sub-flag name the per-key check refuses. Write a
shared setting for one key as BlockSupports["spacing"], or a whole object
through blockSupports().
- BlockRenderResult from @nextlyhq/plugin-sdk/blocks is now
ReactNode | Promise<ReactNode> rather than the engine's unknown, so a
helper typed with it satisfies a block's render.
- BlockRenderArgs.slots is replaced by BlockRenderArgs.renderSlot.
- BlockDefinition.resolve is removed. Nothing ever called it, so a data-loading
function written against it silently never ran; blocks read data through ctx.
- createRevision, pruneRevisions and Revision are removed from
@nextlyhq/plugin-page-builder. They duplicated the content-versioning
support that already ships in core, and nothing in the package used them.
- #512 `8c36bb6` Thanks @mobeenabdullah! - Record the outcome of every event the outbox captures. `success | failure |
- unknown` is the vocabulary the audit and observability schemas converge on, and
- the one field NIST SP 800-53 AU-3(e) requires that the envelope did not already
- carry.
Absence means success, which is what every event recorded so far is: a row is written inside the transaction of a change that commits, so a recorded event is by construction a completed one — and that is also why the column's default is the correct value for existing rows. The field exists so that a refusal, such as a denied publish, can be recorded as the distinct thing it is rather than being indistinguishable from a change that happened.
Additive and optional on the webhook envelope, so existing subscribers are unaffected.
- #513 `c9ef62a` Thanks @mobeenabdullah! - Record which retention window governs each captured event, and shorten the audit
- window to 90 days.
The event table has carried a retention_class column since the outbox shipped,
but nothing ever wrote anything but webhook, so every row was measured against
the short outbox-hygiene window. The class now follows from why the row was
recorded: a row admitted by the audit seam is audit-class and outlives outbox
hygiene, while one admitted only because an endpoint exists stays webhook-class.
A row that is both takes the longer window, since evicting it on the delivery
clock would lose history nothing can reconstruct.
The audit window default moves from 365 days to 90. The previous value was
justified as "SOC 2 practice is a one-year floor", which does not hold up:
neither SOC 2 nor ISO 27001 A.8.15 mandates a period — both require only that
retention be defined and risk-based — and the twelve-month figure is PCI DSS
convention that has spread into the wider discourse. 90 days is where comparable
products land for content activity. A deployment genuinely in PCI scope should
raise auditEventsMaxAgeMs, which is a decision only the operator can make.
auditEventsMaxAgeMs is now raised to eventsMaxAgeMs whenever the webhook
window is the longer of the two, including when it is false. A row admitted by
both the audit seam and an endpoint is labelled audit because that is the
longest retention it needs, so a shorter audit window would have pruned it
earlier than the webhook setting allows — irreversibly, and in a supported
configuration.
Upgrading, by deployment:
- webhooks.audit off (the default, and most installs): nothing changes.
Events are still recorded webhook-class and pruned on eventsMaxAgeMs exactly
as before.
- webhooks.audit on: events that used to be recorded webhook-class are now
audit-class, so they move from eventsMaxAgeMs to auditEventsMaxAgeMs — at
the defaults, from 30 days to 90. That is the intended behaviour, since those
rows are recorded for history rather than delivery, but it retains roughly
three times as many events and the storage that implies. Set
webhooks.retention.auditEventsMaxAgeMs if a shorter window is wanted.
- #489 `3a75d0e` Thanks @mobeenabdullah! - The admin now calls field groups "field groups" in the places that used to say "components".
The field picker, the Schema Builder's field-group editor, the entry form, the entries table badge, the Field Groups list and its empty states, and the dashboard's getting-started panel all carried the old wording, so a page titled "Field Groups" could tell you that you had selected components. Only the words changed: the stored field type, table names and API payloads are untouched, so no data or integration is affected.
- #465 `97bcb2c` Thanks @mobeenabdullah! - Collections and singles with Draft/Published now record when a document first went live, in a new
firstPublishedAttimestamp.
Until now a row only said what it IS. Unpublishing sent it back to draft and erased every trace it had ever been public, even though the inbound links, feeds and search results it collected while live were still out there. Anything that needs to ask "was this address ever public" had nothing to read.
The value is set once, on the first transition into published, and never changes afterwards: it is the date of the first publication, not the most recent one. It survives an unpublish, and it stays empty for an entry that has only ever been a draft. Entries that already existed keep an empty value, because whether they were once published was never recorded and cannot be recovered after the fact.
Collections and singles without Draft/Published do not get the column: they have no unpublished state, so there is no transition to record.
For a collection translated into several languages, the value answers whether the document has been public in any language, since every translation shares one address. Publishing a single translation therefore records it.
The value is set by Nextly alone. A firstPublishedAt sent in a create or update request is ignored, so the recorded date is always one that actually happened.
- #491 `c78afca` Thanks @mobeenabdullah! - When a service raises a typed error, the public result shape drops its
causeandlogContextbefore the boundary rebuilds it, so an operator saw a generic reconstruction with none of the detail the thrower attached. The original is now kept for the request and logged against the samerequestIdthe response carries, so the two can be joined.
An error response can also carry a _devDiagnostics field with that detail, so an author sees why a request failed without reading the server log. It requires TWO signals: NODE_ENV=development AND NEXTLY_DEV_DIAGNOSTICS=1. Set the second in your local env file to switch it on. Neither alone is enough, because Nextly ships pre-built and stays external to your app build, so NODE_ENV is read at runtime and a production deployment started with the wrong value must not be able to disclose it. Production responses are unchanged either way.
- #517 `089a758` Thanks @mobeenabdullah! - Two corrections to how the page builder's isolation check reads names, both of
- which made it reject stylesheets that were correct.
A font family is matched without regard to case, so a namespaced family spelled in capitals is the same family; a keyframe or a layer name is case-sensitive and still is. A comment is whitespace, so a comma inside one no longer splits one name into two.
- #487 `41d7c8d` Thanks @mobeenabdullah! - Localization migration files now record what transition they are for.
nextly migrate:create writes an extra header line on each _locales companion migration naming the transition, the kind of entity it belongs to, and the columns involved. Nothing reads it yet, so applying a migration behaves exactly as before, and files generated by earlier versions keep applying unchanged.
- #518 `1797d27` Thanks @mobeenabdullah! - Record a
login-succeededaudit event when a session is issued.
Failed logins have been recorded since the audit log shipped; successes were not. A trail of failures alone shows that someone tried and not whether they got in, which is the first question asked after a credential leak.
The event is written where the session is issued, not where the flow began. Three handlers issue sessions — password login, second-factor resolution, and the forced first-sign-in password change — so recording it in the login handler alone would have left every user who completes a second factor absent from the success trail, which is the population most worth seeing in it. Recording on an HTTP 200 instead would have the opposite fault: the challenge and password-change legs answer 200 while issuing no session, so a success would be reported for an account that was never reached.
It is recorded last, after the post-login hooks. A hook that throws sends the handler into its failure path, which returns an error and records a failure, so the client receives neither the token body nor the cookies — a success recorded before that point would leave the trail asserting both outcomes for one attempt. Those hooks now run inside the same shared step for that reason: all three handlers ran the identical pair, and the order between them decides whether the trail can contradict itself.
Unlike the failure event it is attributed to the account. Naming the account on a failure is the account-state leak the unified error response exists to avoid; on a success it is the whole value of the record.
Setup records it too. Creating the first administrator hands out a working session without going through the shared login path, so that account — the super-admin — was the one login absent from the trail.
Also fixes an overstated token expiry on the login and setup responses. The
expiresAt they return was derived from a fresh clock reading taken after the
awaited work that follows signing, so it named a later moment than the token's
own exp claim. signAccessTokenWithExpiry now returns the token together with
the expiry it actually carries, computed once and set explicitly, so a caller
reports the truth rather than a parallel calculation that drifts by however long
that work takes — unbounded, since plugin afterLogin hooks run there.
- #477 `302264b` Thanks @mobeenabdullah! - Field-level read access on an expanded relationship now applies to each related row before its parent's
afterReadfield hooks run, matching a direct read. Previously a parent hook was handed a nested child with the caller's denied fields still present, so a hook that copied such a field onto an allowed key exposed it under that key even though the child's own field was redacted afterward.
Behavior change: a field afterRead hook can no longer observe a related row's caller-denied field, so it can neither leak nor mask on one. A value that must stay hidden should be protected with an access.read rule keyed on the caller rather than a hook that reads another field the caller cannot see. Trusted reads (overrideAccess) are unaffected, since field access is skipped for them.
- #499 `1825c8f` Thanks @mobeenabdullah! - Catch every spelling of a Field Group field name that collides with one of its table's system
- columns, not only the two that were listed.
CreatedAtreaches the samecreated_atcolumn as -
createdAtdoes, and was accepted. Names are now compared as the column they become, so a field - declared with a plugin-contributed type is checked too — its type registers after the config is
- read, and it was previously skipped.
A Field Group field that references another Field Group may take any name that a Field Group
instance does not already use for itself: not id, which is the instance's own identity, and not a
name that converts to created_at or updated_at, which a read would fill with the row's
timestamp instead of the referenced data.
- #516 `00fee42` Thanks @mobeenabdullah! - Breaking (plugin authors):
ctx.services.collections.createEntry,updateEntryand -
deleteEntrynow resolve to{ message, item, warnings? }instead of the bare row.
This is the same envelope the Direct API and the REST API already return, so the same failure is
equally visible however the write was made. Previously a plugin was the ONLY caller of a write
that could not see a post-commit hook failure: afterCreate / afterUpdate / afterDelete run
once the row is durable, so a handler failing there cannot un-save it — the write reports success
and the failure travels beside it as warnings. The plugin facade never opened a collector, so
those failures were invisible to the plugin that caused them.
Migration is one property access:
// Before
const post = await ctx.services.collections.createEntry(slug, data, {
as: "system",
});// After
const { item, warnings } = await ctx.services.collections.createEntry(
slug,
data,
{ as: "system" }
);
item.id;
if (warnings)
ctx.logger.warn("side effects failed", { id: item.id, warnings });
`
deleteEntry reports item as { id }, since there is no row left to return. Reads
(listEntries, findEntryById, count) and createMany are unchanged.
- #483 `326ac0d` Thanks @mobeenabdullah! - A hook that throws in a post-commit phase (
afterCreate/afterUpdate/afterDelete) now reports the failure to the caller instead of only to the server log. The write still reports success, because the row is durable and a side-effect phase cannot change it, but the result carries awarningsarray naming the phase, the entity and the error code so an integration can react to a side effect that did not run. The field is present only when something failed, so an ordinary response is unchanged. It appears on the REST mutation and bulk envelopes and on the Direct API'sMutationResult,DeleteResultandBulkOperationResult.
Breaking (Direct API): nextly.updateSingle() now returns the same { message, item } envelope the collection mutations return, instead of the bare updated document. Singles run the same post-commit phases as collections, so this is what gives their hook failures somewhere to be reported — and it removes the one mutation that did not report its outcome like the others. Read the document from .item:
// before
const settings = await nextly.updateSingle({ slug: "site-settings", data });// after
const { item } = await nextly.updateSingle({ slug: "site-settings", data });
item.siteName;
`
- #511 `51d2469` Thanks @mobeenabdullah! - A failure now chains the error it actually came from onto what the caller receives, through
- every boundary that rebuilds one: REST routes, the Direct API, the singles route, the
- plugin-facing collection facade, the bulk-by-query paths and the version writes. Previously
- only typed failures carried their origin, and only on the Direct API, so a connection drop or
- a constraint rejection arrived with nothing naming what actually went wrong. The status-derived
- rebuilds — a code-less 404, 403, 409 or 500, which is exactly what a raw driver rejection
- produces — dropped it too.
NextlyError.notFound, .forbidden and .conflict accept a cause alongside logContext,
matching .internal.
One place now builds the error response body, so plugin routes answer with what every other
route answers with. Three consequences for a plugin route:
- Failures now carry _devDiagnostics in development, which this surface never had.
- A handler that throws a non-NextlyError still answers 500, but the thrown error is now
chained onto it instead of discarded.
- A 401 or 403 now returns the canonical { error: { code, message, requestId } } body with
application/problem+json, matching the rest of the API. It previously returned the legacy
{ data: { ... } } body with application/json, so a single plugin route answered rejected
requests and failing handlers in two different shapes. A client reading a plugin route's
auth-failure body needs updating; one reading the status or a handler failure does not.
- #497 `a4d86c1` Thanks @mobeenabdullah! - Harden nested field-level read access against
afterReadhooks that reshape a - read response. A related row's presentation is its own collection's authority, so
- the response's related rows are now rebuilt from the versions the read sanitized
- rather than inspected for tampering: whatever a source collection's
afterRead - hook did to a related row — reintroducing a denied field, cloning or reshaping the
- row, replacing, appending, reordering or removing its nested group/repeater rows,
- or returning a rebuilt document — is discarded. The rebuild runs after every hook
- phase, so one phase cannot hand the next a contaminated related row to copy from.
Closes a field-hook exfiltration path on related rows. A field hook belongs to one field but is handed the whole row, so a hook on an ALLOWED field of a related row could read a DENIED field beside it and return it as its own value — and the access pass that ran afterwards, judging each field by its own rule, had no reason to remove the copy. The target collection's field access now runs BEFORE its field hooks and again after, the same order a direct read of that collection uses: a row reached through a relationship may be redacted more strictly than the target's own endpoint, never more loosely.
Also fixes a related-row read-access gap for a relationship that declares a single
target as an ARRAY (relationTo: ["posts"]). That form stores and expands as the
discriminated { relationTo, value } pair, but the nested read decided the pair
shape from the NUMBER of declared targets and so treated the wrapper as the row
itself — evaluating the target collection's field access.read rules against an
object holding only relationTo and value, which matches nothing. A field the
target collection denies was returned inside the wrapper. The shape is now read
from how the target was declared, in one place shared by every reader.
This also removes the previous release's over-stripping: a related row a hook merely copied is no longer returned with its access-controlled fields denied, it is returned correctly sanitized, and the development-mode warning about reshaped rows is gone. A denied source field stays hidden from the source collection's own field hooks so it cannot be copied onto a selected field.
Notes for hook authors. A source collection's afterRead hook can no longer change
how a related row appears in the response, including its readable fields: transform
the related collection's own fields with that collection's field hooks instead.
Filtering or reordering a hasMany relationship still works, since that shapes the
source field rather than the related rows. A populated related row a hook invents
(one the read never expanded, so no collection's read rules were ever applied to it)
is returned as the bare reference it names rather than as an object.
- #486 `04fb6ab` Thanks @mobeenabdullah! - Style values are read more carefully in three places. A composite no longer
- builds an unbounded amount of issue text before its allowance is checked, an
-
attr()fallback is validated as the single value it substitutes rather than as - an arithmetic expression, and an expression is still judged where it can be even
- when part of it cannot be read.
- #501 `fcdcd2d` Thanks @mobeenabdullah! - The style compiler now accounts for every shape of persisted data it cannot use.
- A state map, a breakpoint map, a
visibilityenvelope or itsdevicesmap that - is not an object applies nothing, and each is reported rather than skipped, so a
- document with values and a page with no CSS are always connected by a warning.
The node walk is bounded by what it READS rather than by what it could use, so an array of malformed entries can no longer pass the node cap without tripping it.
- #492 `379c16a` Thanks @mobeenabdullah! - The engine can now compile a page's stored styles into CSS.
compilePageCss - turns a document and its site context into one stylesheet plus the class each
- node should carry, reading only persisted data: styles are never gathered while
- something renders, so a block cannot lose its styling by not being on screen
- when the sheet was built.
Design tokens compile to the custom properties they read, logical values stay
logical so one stored style is correct in both reading directions, states
compile to :hover, :focus-visible and :active, and both breakpoint axes
compile to media and container queries. The same document always produces the
same bytes.
States are emitted inside :where() so they add no specificity, and every rule
is decided by source order instead: a node's own value beats its block type's
default at every width, and a value set for a state beats a base value set at a
narrower breakpoint.
A value the validator refuses is left out of the stylesheet and reported rather
than written, whether or not the caller validated first. The same holds for
everything the compiler cannot act on: a block type that is not a namespaced
slug, a style state it does not recognise, a breakpoint id that resolves to more
than one definition, two nodes sharing an id, and a malformed envelope are all
left out and named. StyleCompileContext takes the document limits, so the
node walk stops where validation would have.
- #503 `387e593` Thanks @mobeenabdullah! - Stylesheets compiled by
@nextlyhq/blocks-enginenow sit one specificity notch - higher, so ordinary site CSS no longer beats a value set in the builder by
- accident. A rule like
.content .card h1used to win over a block's own colour - and leave the author with a style that silently did not appear.
This applies to that engine's output. @nextlyhq/plugin-page-builder renders
through a compiler of its own that does not yet follow these weights, so pages
rendered through it are unchanged by this release.
Overriding on purpose still works: an unlayered selector that beats the builder's
specificity wins, and so does !important, because the compiler deliberately
never writes it. Two things are worth knowing.
If your CSS lives in a cascade layer, as Tailwind's does, layer order is settled
before specificity and the builder emits an unlayered stylesheet, so adding
classes inside an @layer will not win. Write the override unlayered, or use
!important.
If the property you are overriding is mid-transition, the transitioning value
outranks every author declaration including !important until the transition
ends. Add transition: none !important to your rule if that applies.
- #466 `4dc8a46` Thanks @mobeenabdullah! - Add the style-property catalog to the blocks engine: the set of style properties a block may set, each with its value shape, the CSS it emits, and the design tokens it accepts. Storage keys are logical, so one page renders correctly in both left-to-right and right-to-left languages without a separate copy. Style values are checked for safety and for being the kind of value their property takes, before they reach a stylesheet.
The built-in block supports sub-flags now match the catalog. A block declaring spacing.blockGap, color.background, or border.width/style/color will fail to register and must use the group's current flags instead; the error names them.
- #488 `a4c6092` Thanks @mobeenabdullah! - Document validation can now check design-token names and class ids against the
- site that will render them. Both are optional: validation is given the site or
- it is not, and without it these names are not checked at all. An unresolved name
- is always a warning, never an error, so renaming a token or retiring a class
- never makes a stored document unpublishable — including when a rename leaves
- more unresolved names than one report can carry, which is now said separately
- and does not stop the checks that decide whether a document is valid.
- #494 `9653096` Thanks @mobeenabdullah! - Reject a Schema Builder field named
createdAtorupdatedAtwhen the name is chosen, rather - than letting it fail later as a database error. Both snake-case onto a system column and land in
- the same
CREATE TABLEtwice, so a collection carrying one could never be created.
Internally, what a system column is now lives in one declaration per column instead of ten hand-written lists across the codebase, so a column added in future reaches the schema, the write paths, the response shapes and every validator at once.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.50
AlphaReleased 17 packages at 0.0.2-alpha.50 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #436 `5e64acc` Thanks @mobeenabdullah! -
beforeOperationhooks are now declared and registered as what they are. They receive the operation'sargs-- the data, id or where clause it is about to use -- rather than a document, so they are typed asBeforeOperationHandlerand registered throughregisterBeforeOperation()/registerBeforeOperationHook(). Previously they were declared as ordinary hook handlers, so a handler written against the documented type readcontext.dataand gotundefined. Handlers for the other eight phases are unaffected.
- #455 `80fdee6` Thanks @mobeenabdullah! - A block that supplies its own editor component now loads without a hand-written import.
A block can name a custom inspector or canvas component through editor.component. That is a component path like any other admin contribution, so it now goes into the generated admin import map alongside plugin pages, settings and views — the editor bundle picks it up with no host wiring.
Paths are read from what plugins declare, so generation needs no plugin to boot. A block registered imperatively at runtime contributes no path, the same rule the block manifest follows. An app whose only components come from blocks now gets an import map too, where before none was written.
- #450 `7a36ab6` Thanks @mobeenabdullah! -
nextly generate:typesnow writes a block manifest listing every block your plugins declare.
Until now the only way to ask what blocks an app has was to boot it and inspect the registry, which is not available to an editor build, a docs page, or an agent writing a page document. The manifest states it as a file beside your generated types: each block's name, schema version, description, worked example, prop schemas, style capabilities, slots, and the plugin that declared it.
It is written from what plugins declare rather than from the running registry, so generation stays a pure read of your config: no plugin boots and no database opens. Blocks registered imperatively at runtime are not listed, because they cannot be known without running the plugin. No file is written when nothing declares a block.
- #476 `6cb97df` Thanks @mobeenabdullah! - Collections and singles created through the Schema Builder now get their system columns from the same definition the runtime schema and the migration diff already use, instead of a separate hand-written copy.
The copy had drifted. A Builder-created table declared createdAt and updatedAt as required while the rest of Nextly described them as optional, so nextly db:sync proposed a change to those columns on every Builder collection, and applying it rebuilt the table. On SQLite that rebuild also dropped the timestamp defaults. Both now agree, and the sync proposes nothing.
Newly created Builder tables declare the two timestamp columns as optional. Existing tables are brought in line by one schema sync, which preserves their rows.
The practical effect is that a system column added to Nextly in future reaches Builder-created tables as well as code-first ones. Previously it reached only code-first tables, and reading a Builder collection or single failed with a missing-column error.
- #480 `3b39129` Thanks @mobeenabdullah! - A dev-server config reload now applies hook edits only when the reload advanced the runtime in every dimension. Previously a reload that applied part of a config — one collection's schema change refused while others landed, or a field-tree sync that failed for a scope — could still publish the new handlers, leaving them running against tables and serialized field metadata the save had not reached. A hook edit that shares a save with a refused schema change now takes effect on the next save instead; a hook edit on its own changes no table, so it still applies immediately.
- #441 `55d3aa6` Thanks @mobeenabdullah! - A plugin can now add its own blocks to the page builder.
The page builder exposes its block registry as a service, and a contributing plugin reaches it from init with blockRegistry(ctx).register(myBlocks). Registering this way rather than by importing the engine is what makes the timing safe: the block registry is cleared and rebuilt on every boot, so a direct call can land before the rebuild and lose the blocks with no error, while services are recorded before any plugin's init runs. Each block is attributed to the plugin that registered it, taken from that plugin's own identity, so a name collision names the packages actually responsible.
defineBlock and the block types come from @nextlyhq/plugin-sdk/blocks, keeping the SDK the one stable surface a plugin author imports from while a plugin that has nothing to do with blocks never pulls the engine into its type graph. The registry itself comes from @nextlyhq/plugin-page-builder/blocks, since it belongs to that plugin rather than to core. Custom supports are registered through the same service as blocks, so both share the per-boot reset and neither collides on a second boot. Nextly core is unchanged: it carries no blocks contribution key and does not depend on the block engine, because contributing blocks is contributing to the page builder rather than to the framework.
- #464 `a3b1f48` Thanks @mobeenabdullah! - Editing a published entry on a drafts-enabled collection now works as a proper draft and publish flow.
When a collection has drafts enabled, editing a published entry saves your changes as a pending working draft instead of overwriting what is live. The editor shows a "Changed" status while a draft is pending, a Publish button promotes it to the live document, and a confirmed "Discard draft" action throws the pending edits away and restores the published version. The read API also surfaces the working draft to a trusted editor through ?draft=true.
- #428 `341890f` Thanks @mobeenabdullah! - You can now edit a published document without changing what visitors see.
Saving changes to a published document (without choosing Publish) now keeps them as a pending draft: the live version stays exactly as it was until you publish. Clicking Publish brings the whole pending draft live at once, including fields the Publish action itself did not resend, and Unpublish does the same in reverse while returning the document to draft. Trusted editors see their pending edits when they open the document; anonymous and published-only reads always get the live version. This applies to non-localized collections that have draft/published status with drafts-enabled versioning; localized collections are unchanged for now.
- #451 `9586432` Thanks @mobeenabdullah! - Add a
draftread option to fetch a document's pending working draft.
nextly.findByID({ collection, id, draft: true }) and the REST ?draft=true query parameter now return a published document's pending working draft in place of the live version. Access is gated on edit capability: a caller who cannot update the document still receives the published version, so this never exposes a draft to a read-only reader. Only non-localized collections with draft/published status and drafts-enabled versioning have a working draft to return.
- #434 `b8c4941` Thanks @mobeenabdullah! - Groundwork for the field group storage migration. The engine can now plan a complete run in either direction and resume one that was interrupted. A rename also carries the pointers that address the table it moves: a field group nested inside another records its parent by physical table name, so renaming the parent without rewriting those records would leave the nested content in place but unreachable, and reads would return nothing rather than fail.
Nothing runs it yet. No command invokes the migration and no database is changed by installing this; the entry point ships separately, once the engine is covered end to end against real PostgreSQL, MySQL and SQLite servers.
- #463 `a8f7a78` Thanks @mobeenabdullah! - A write refused inside a field group is now reported as the refusal it is. A blank required field returned a generic server error with no per-field detail, because every dialect adapter re-classified anything thrown out of a transaction as a database failure — including an error the application raised deliberately to roll the write back. Collections were affected on create and update; singles already behaved correctly.
- #459 `5d962d2` Thanks @mobeenabdullah! - Field validators inside a field group now receive the write's request context, so a plugin field type whose rule depends on
req.userbehaves the same nested in a field group as it does at the top level. Previously that rule saw an empty context and accepted every value.
Adds nextly generate:manifest, which emits the block manifest on its own, and --check, which writes nothing and fails when the committed manifest no longer matches the config. The manifest also publishes its own schema, and generation now refuses to write a document that schema would reject.
- #469 `13e3578` Thanks @mobeenabdullah! - Schema applies and the
nextly migrate/nextly upgrade --reconcile-corecommands now address the field-group registry and each field group's storage by the names the database actually holds, instead of the names this release would have created. Without this, a database whose field-group storage had been renamed could have an empty second registry created beside the populated one, after which the app would read the empty one and its field groups would appear to be gone.
- #472 `84f8a15` Thanks @mobeenabdullah! - The field-group storage migration now re-checks the ledgers it rewrote before it settles, and refuses rather than reporting success when a row still carries the old vocabulary. Without this, content written while the migration was running could be left in the old format and the run would complete silently, with the problem only appearing in a much later release.
- #454 `11f75b5` Thanks @mobeenabdullah! - Field-group storage is now addressed by the name the database actually holds.
The storage migration renames the field-group registry table and each data table's type discriminator. Every reader resolves those names from the database catalog instead of a constant, so a database that has run the migration and one that has not are both read correctly by the same build. Nothing about stored data changes, and a database that has not migrated behaves exactly as before.
- #429 `151efce` Thanks @mobeenabdullah! - Turning localization off in
nextly.config.tsnow brings your content back onto the main table. Previously only the Schema Builder toggle did this, so settinglocalized: falsein configuration left every translation in a table nothing read any more and fell back to whatever the entity held before it was localized. Turning localization on again no longer trusts the stale rows that companion still holds.
Enabling localization and Draft/Published in the same edit now applies. It used to fail part-way and could never succeed on a retry, because the copy read a status column the schema push had not added yet.
Saving a localized entity is faster, and on PostgreSQL a class of failure is gone. Every localized write used to ask the database whether each translation table existed — once per entity, plus once per field-group type in the payload, before the write and again inside it. That answer is now resolved once and remembered. The read that builds the response used to discover the same thing by running its query and catching the failure, which on PostgreSQL aborts the whole transaction: writes that should have succeeded failed with current transaction is aborted, blaming an unrelated statement.
When a translation write is refused, the message now names the right fix for where you are running. Production is told to run nextly migrate instead of nextly db:sync, which is a development tool and cannot help there — and nextly migrate now creates missing translation tables and repairs installs that enabled localization before Nextly began recording it.
Turning localization off now brings an entry's publishing state back with its content. Publishing is per language while an entity is localized, so an entry published only under a language that is no longer your default carried that state on its translation row alone — and restoring the content without it could put a draft in front of the public, or make live content disappear.
Two processes enabling localization for the same entity at once — a db:sync alongside a running dev server, say — no longer both do the work. Only one holds the transition; the other stops and says so, instead of racing to seed the same rows or overwriting translations written since the first one finished.
If you open your own transaction and call createEntryInTransaction / updateEntryInTransaction / deleteEntryInTransaction (or their batch equivalents), call warmLocalizedReadiness(collectionName) before you open it. Nothing fails if you do not, which is why it is worth knowing: the write commits, but the version history it records and the webhook event it sends will be missing every translated value from your localized components.
- #452 `6536365` Thanks @mobeenabdullah! - Turning localization off now brings an entry back with the publishing state it was actually published under. Publishing is per language while an entity is localized, so an entry published only under a language that is not your default carried that state on its translation row alone — and the disable drops that table straight after restoring, so the state was lost for good. A draft could become publicly visible, or live content disappear.
- #440 `ed94b78` Thanks @mobeenabdullah! - Plugin options on a code-defined user field are no longer refused when two of them share a reference, and a sparse array in them is now rejected rather than silently reshaped.
The JSON-shape check treated every object it had already visited as a cycle, so one object referenced from two places within a single option was refused even though it serializes correctly at both. It now tracks only the objects on the active path. It also walked arrays with a method that skips holes, so a sparse array passed the check and then had each hole written as null, handing the plugin's component different data than was declared.
- #442 `5785ee5` Thanks @mobeenabdullah! - Apply read hooks per collection, and hand them the values a caller sees.
A read hook that reads a different collection now runs that collection's own hooks instead of silently skipping them, so a hook cannot reach rows the other collection withholds. A hook reading the collection it is already running for still skips them, which is what stops it calling itself without end.
afterRead is now handed decoded JSON values rather than the storage encoding
SQLite returns, so a hook reads the value the field was configured with instead
of a string. Field hooks are also declared with the context they are actually
given, which includes the field's value and name.
- #468 `375d796` Thanks @mobeenabdullah! - On MySQL, the internal description of a collection table's
created_atandupdated_atcolumns said they had no database default, while the tables actually created for them do have one (CURRENT_TIMESTAMP). The schema comparison that decides what a migration should contain was reading the description rather than reality, so it could see a difference that was not there. The description now matches what is created.
- #460 `cbaa8d8` Thanks @mobeenabdullah! - Run collection and single
beforeChangehooks after validation, not before
A beforeChange handler declared on a collection or single used to be
registered onto the beforeCreate/beforeUpdate queue, which fires before the
schema rules are enforced. The phase documented as the last chance to shape a
stored value therefore ran on data that had not been validated, and it ran even
for writes that were about to be rejected. The field-level hook of the same name
was already in the right place, so the two beforeChanges meant different
moments.
beforeChange is now its own phase, executed immediately after the validation
gate on every write path: collection create and update, both of their
transactional forms, the transactional single paths, and the single update
service.
Singles gain beforeValidate, which they did not have. Moving beforeChange
past the gate would otherwise leave a single with no hook running before
validation at all, so the phase takes the pre-validation execution point
beforeChange vacated. A single and a collection now agree on both phases.
This changes when existing handlers run. A beforeChange that SUPPLIES a value
the schema requires now runs too late to satisfy it, because validation has
already been applied; move that work to beforeValidate, which runs before the
gate on collections and singles alike. This includes the Schema Builder's
pre-built "Auto-generate Slug" hook when it targets a required field of your
own. The framework's own slug/title derivation is unaffected: it does not
run as a hook.
What a beforeChange handler returns is written without being re-validated.
That is the point of the phase, and it is now true rather than accidental.
- #443 `bdcde29` Thanks @mobeenabdullah! - Clear the hook registry when services shut down.
The registry is process-global and outlives the DI container, but handlers are registered from config on every init. Re-initializing in one process therefore left the previous instance's handlers in place and appended a fresh copy of each, so every hook ran twice per operation and the dead instance's handlers ran alongside the new ones.
- #467 `8a4d4a3` Thanks @mobeenabdullah! - Bind the Direct API for hook contexts at registration
req.nextly is now bound for hook contexts from the moment services are
registered. It previously resolved through a binding that getNextly() created
as a side effect of its first call, so a process that never called it — which is
any REST or admin write — handed every hook undefined, including the worked
example in the collections guide.
- #473 `9dfbd80` Thanks @mobeenabdullah! - Apply hook edits without restarting the dev server
Editing a hook in nextly.config.ts had no effect until the process restarted,
and deleting one left it firing. A config reload re-read the file but the
registry kept the function objects registered at boot, so the hook that ran was
always the one from startup.
Collection and single hooks are now rebuilt from the reloaded config. Clearing
them is safe because the registry records who registered each handler: a
reload replaces only what it can rebuild, and leaves alone both a plugin's hooks
(the form builder registers directly on forms, and plugins do not re-run on a
config reload) and any registered imperatively through registerHook() (nothing
re-runs those at all). Unregistering is likewise scoped to the caller's own
registrations, so a plugin removing a handler it shares with the config no
longer removes the config's instead.
A save that changes a hook and a schema at once is handled as one unit: the new
handlers are published only once the schema they were written against has landed,
so a request served while the reload is still running never sees a hook reaching
for a column that is not there yet, and a refused schema change leaves the
previous handlers in place. Replacing them also keeps their position, so a config
save no longer reorders a chain it is not changing. Switching a plugin to enabled: false
now stops everything it contributed -- the hooks its collections and singles
declared, and the ones it registered itself, which are suspended rather than
dropped so re-enabling it in the same session brings them straight back. Deleting
or renaming a collection stops its hooks too: a removed entity's table is kept until nextly prune, so it stayed
addressable and went on running hooks its config no longer declared.
Deleting a plugin from the config stops its hooks as well as disabling it does, and a plugin that was disabled stays that way when it is later removed.
Registering straight into the registry that getHookRegistry() hands out now
marks the handler as the app's, matching registerHook(). Only the registrars
that read the config claim ownership a reload may replace, so a handler nothing
can rebuild is never removed by one.
- #445 `d20e9d3` Thanks @mobeenabdullah! - Keep a typed error's status and code across the service boundary.
A service raising authRequired, rateLimited, serviceUnavailable or any
other 401 reached a REST caller as a generic 500, because the boundary rebuilt
errors from their HTTP status and only four statuses had a branch. A 400 was
rebuilt as a validation failure whatever code it carried, so a caller was told
its data failed validation when it had not been validated.
Errors are now rebuilt from the canonical code the envelope already carried, with the status mapping kept as the fallback for envelopes that carry no code.
- #449 `3dc6927` Thanks @mobeenabdullah! - A field masked by its collection stays masked when read through a relationship.
A field's afterRead hooks are how it masks itself on the way out, and they ran
only when the collection was read directly. Reaching the same row through a
relationship returned the unmasked value. They now run over the assembled
document, so a nested row gets its own collection's treatment at every depth,
and a hook that masks based on the row's own relations sees them expanded
rather than as raw ids.
- #446 `4e5064e` Thanks @mobeenabdullah! - A plugin can now declare data for another plugin statically, and the page builder registers contributed blocks from it.
contributes.declarations is the static counterpart to contributes.services. A service is a factory, so what it provides is knowable only once a plugin has booted — and nextly generate:types boots nothing, reading the config alone. A capability offered only through a service is therefore invisible to generation and cannot appear in generated types, an import map, or a manifest.
A block contributor can now declare its blocks instead of registering them by hand, and the page builder registers them at boot from the same declaration the tooling reads, attributed to the plugin that declared them. Registering imperatively from init still works for a plugin whose block list depends on runtime state.
- #438 `7b0dddf` Thanks @mobeenabdullah! - The page builder now states the core version it actually needs, an empty default is checked against the column it will occupy, and a user field's plugin options are refused when JSON cannot hold them unchanged.
@nextlyhq/plugin-page-builder requires nextly 0.0.2-alpha.49 or newer, the release that first exports pluginField. Installed against an older core it now fails at install rather than throwing when a blocks() field is evaluated.
A single's default that resolves to an empty value is validated against the field's storage primitive and its type's own rules, instead of being treated as a field the writer left alone; a number-backed default of "" no longer reaches the insert. Options declared on a code-defined user field are refused when they are values JSON cannot represent — a Date, Set, Map, BigInt, function or cycle — which previously either reached the admin component reshaped or failed the whole startup sync.
- #435 `082fa67` Thanks @mobeenabdullah! - Plugin field types now work on every surface that accepts fields, and a column added to an existing table gets the same storage class the ORM binds.
A contributed field type can be declared in contributes.extend and defineFieldGroup, not just in collections and singles, and pluginField() keeps the shape it was given so a plugin's own factory stays typed. The page builder exports isBlocksField again and reaches core only through @nextlyhq/plugin-sdk, which now carries the field contracts a contributed type needs; it also states the core version its blocks() factory actually requires, so installing it against an older core fails at install rather than at runtime.
A contributed default is checked against the type's storage primitive before it reaches the database, disabling a plugin no longer leaves its empty-value callback registered, and nextly build and migrate:check now refuse a field type no installed plugin offers instead of generating types for a schema production would reject. Field names are validated even when the field's type is deferred to boot, so a duplicate or SQL-reserved name can no longer reach schema generation.
Plugin options declared on a code-defined user field are persisted and reach the contributed admin component, and a number field added to an existing table is created as the integer the ORM binds rather than NUMERIC/DECIMAL/REAL, honouring dbType: "decimal" and format: "float" for fields that ask for fractions.
- #456 `1bc29b5` Thanks @mobeenabdullah! - Editing a published entry's title no longer changes its URL. The slug follows the title while an entry is still a draft and stops once the entry has a public address, at which point it changes only if you edit it yourself. Previously a title edit silently retired the published address and every link to it started returning a not-found page.
An entry counts as publicly addressed in three cases, each of which was a way to lose a URL: it is published wherever its slug is served; it lives in a collection with no draft/published lifecycle, where saving is publishing; or you have published it at least once while the editor has been open, so unpublishing to make an edit does not put the address back up for grabs.
Where the slug is served depends on the slug field. The slug a collection gets by default is shared across languages, so one address serves all of them and any published language keeps it frozen: editing the title of a German draft no longer rewrites the URL the published English version is being served at. A slug you have explicitly localized is genuinely per language, and follows only that language's status.
When you do change a public entry's slug, the editor says so before you save: the public URL changes and the old one stops working. That notice now also appears in the quick-edit form opened from a relationship field, and it clears once the change is saved rather than lingering against the URL you already replaced.
- #439 `f2c6e97` Thanks @mobeenabdullah! - Read hooks now shape the query they precede.
beforeOperationreceives the caller's ownwhere(it was handed an empty one),beforeReadreceives whatbeforeOperationsettled on, andbeforeRead'sreturn narrows the rows the read returns instead of being discarded.countEntriesruns the same chain, so a total describes the same rows a list would return rather than counting rows the list withheld.
- #474 `7c3b9f2` Thanks @mobeenabdullah! - On SQLite, the
createdAtandupdatedAtcolumns of collection and single tables now carry a database default, matching PostgreSQL and MySQL and matching what the Schema Builder has always created.
Nextly sets both on every write, so content created through the admin panel or the API is unaffected. The difference shows up for rows written another way, such as a direct insert or a data import: on SQLite those stored no timestamp at all, and the value read back as null.
Existing SQLite tables pick the default up on the next schema sync, which rebuilds the affected tables in place and preserves their rows. Rows that already hold a null timestamp keep it, because a default applies only to inserts that omit the column.
- #481 `e604c52` Thanks @mobeenabdullah! -
createTestNextlyno longer resolves the Direct API while building its return value.t.nextlyis now resolved when it is read. Resolving it registers thenextlyDirectAPIcontainer binding as a side effect, and that binding is where a hook'sreq.nextlycomes from, so the old eager call meant the binding always existed under the harness whatever the code under test did. Property access is unchanged for callers; a test that wants to assert something aboutreq.nextlyshould do so before readingt.nextly.
- #479 `f7fb1fb` Thanks @mobeenabdullah! -
nextly migrateno longer fails outright when a localized project's companion table already exists but holds no rows yet, which is what a dev-server boot leaves behind. A project whose companion was already filled bydb:syncstill needs the follow-up fix tomigrate:create.
- #447 `ab6795f` Thanks @mobeenabdullah! - A hook that throws after the write has committed no longer fails the write.
afterCreate, afterUpdate and afterDelete run once the row is durable, and
a throw there reported the operation as failed with no entry returned. Callers
could not learn the id of the row that existed, and a retry wrote it a second
time. These phases now report their failures instead of raising them: the
operation succeeds, the error is logged with its phase and collection, and the
remaining handlers still run. beforeCreate and the other pre-write phases are
unchanged -- refusing a write is what they are for.
- #475 `f75c29f` Thanks @mobeenabdullah! - The field-group storage migration now re-checks the collection, single and field-group registries before it settles, so a definition saved while a run is in flight can no longer leave a database reporting success over storage that is only partly migrated.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.48
AlphaReleased 17 packages at 0.0.2-alpha.48 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #417 `1f81cf3` Thanks @mobeenabdullah! - Read every related row through one code path, so a capability added to relationship population applies everywhere a relationship is populated instead of at whichever call sites were remembered.
- #423 `2f05141` Thanks @mobeenabdullah! - Fixes PostgreSQL index introspection reading indexes from the wrong table. Table names are unique per schema rather than per database, so a table with the same name in another schema had its indexes merged into the one being inspected. That could hide an index that needed creating, or report one that was never there.
Refuses to run a schema sync while a field group storage migration is in flight. Mid-run some tables carry their old names and some their new ones, and the registry rows pointing at them move one step at a time, so a sync during that window could delete storage it could not account for.
Also further groundwork for that migration: it can now execute its rename steps and check its own work. A table, its localization companion and the registry row pointing at them move as one step, and on PostgreSQL and SQLite they commit together. MySQL applies a schema change as soon as it is issued, so there the halves land in sequence and a resume completes whatever did not; a reader in that window sees a table as missing rather than reading anything wrong. Every step verifies against the database rather than trusting that it ran, and index survival is checked by name, so an index dropped and replaced by another is caught rather than passing on an unchanged count. Nothing calls the migration itself yet.
- #424 `0538f4f` Thanks @mobeenabdullah! - Let a form be updated without resending its fields. Changing a form's name or settings failed with "Form must have at least one field" because an absent
fieldsin the patch was treated as an empty one.
- #422 `a4dad07` Thanks @mobeenabdullah! - Emit a
form.submission.createdwebhook when a form submission is created. The event type was already subscribable in the admin UI but had no producer, so an operator could subscribe to an event that never fired.
Form submissions carry visitor-entered answers plus ipAddress/userAgent, so the submissions collection suppresses the PII-bearing entry.* events. It now instead emits a curated, metadata-only form.submission.created carrying only which form, when, and the status — never the answers, IP, or user agent. The event is recorded in the same transaction as the submission, so it commits atomically and is never delivered for a rolled-back write.
This is driven by a new declarative webhooks.emit collection option ({ event, fields }): any PII-bearing collection can replace its default entry.* events with a safe curated one that ships only an allowlisted set of fields (default-deny). The resource kind is derived from the event name.
- #421 `83ed5c9` Thanks @mobeenabdullah! - A string stored in a JSON field no longer fails the write on PostgreSQL and MySQL.
A field backed by a JSON column accepts any JSON document, a plain string included. A string that is not itself encoded JSON was passed through to the driver as bare text, which PostgreSQL and MySQL reject as invalid JSON, so storing "hello" in a json field failed the write outright. On SQLite, where the column is plain text, it was stored in a form no read could recover as what was written. Such a value is now encoded, so it round-trips as the string it was.
A string that already parses as JSON is still passed through untouched, so content a previous write encoded is not wrapped a second time.
- #415 `0e18a97` Thanks @mobeenabdullah! - Apply a target collection's read rule when it filters on one of that collection's localized fields, so populating a relationship returns the rows the rule permits instead of withholding every one of them.
- #405 `e1467e8` Thanks @mobeenabdullah! - Plugin-contributed field types are now first-class in generated output, in the manifest, and in the validation a plugin can reuse.
nextly build emitted nothing at all for a custom field type: the generators test membership of the built-in list, so the field was skipped while its value was still stored, leaving apps with no generated type and no schema entry for it. A type now states its own rendering through PluginFieldType.codegen, receiving the field as declared so a type whose options narrow what it stores can narrow what it generates.
A type's options can now be held in a pluginOptions container core never reads, so an option may use a name the field schema already declares — options, fields, admin, label — which was previously judged against the core meaning and refused. Options written directly on a field are still read, and a type is handed one flat view of both, so where an option was stored is not something a plugin author tracks.
A user field whose type a plugin contributed can now be declared from code with pluginUserField(), which was previously impossible without a cast: UserFieldConfig admits only the built-in shapes, and widening it to accept an unknown type token would have made a malformed built-in declaration pass too.
validateFieldValues is now available from the plugin SDK, marked experimental until a first-party plugin depends on it, so a plugin storing structured content of its own applies the same rules a write does rather than reimplementing required, the per-type checks, and every plugin field type's validate.
Several correctness fixes ride along, most of them about a value reaching a column its type cannot hold.
A JSON column stores a JSON document, and true or 42 is a document as much as {} is; only objects were encoded, so a scalar reached the driver as its own type and could not round-trip through a SQLite text column. The four write paths that each carried their own copy of that encoding now share one.
A value written to a custom user field was never checked against the column its type stores in, and a failed user_ext write is read as the extension table being absent — so the user was created without the value, with extensions disabled for the rest of the process, rather than the write being refused. Such a value is now refused with the field named. A required single field backed by a plugin type was seeded with the wrong kind of value for the same reason, which could stop the single being created at all.
nextly build now generates types for a project made only of singles, field groups or user fields, where it previously wrote nothing or left a stale file, and narrows PermissionSlug and EventName as generate:types does — a deployment build no longer widens types a development run had narrowed. db:sync --watch now keeps watching such a project too, instead of exiting its watch loop and never re-syncing.
A key named __proto__ was silently dropped when rebuilding an object from data nobody validates, which lost it from a delivered webhook envelope, from a stored version diff, and from the declaration a plugin validator judges. And db:sync --watch could classify one config's columns with another config's field types, because a reload replaces the process-wide registry while the previous sync is still running; work now resolves against the config it started from, and a reload whose watcher was replaced mid-flight no longer applies its result or leaves its registrations behind.
- #418 `21bb5b3` Thanks @mobeenabdullah! - Stop serving unpublished rows through relationships. A related row is now filtered by Draft/Published exactly as a direct read of it is, so a published document linking to a draft one no longer discloses that draft's contents.
- #414 `45faba9` Thanks @mobeenabdullah! - Emit
user.createdanduser.deletedwebhook events. Both were already advertised as subscribable in the admin UI but had no emit sites, so an operator could subscribe to events that never fired. They now record into the transactional outbox atomically with the account change, through a new Drizzle-transaction recorder (recordEventInTx) that lets services running onBaseService.withTransaction— like the auth service — participate in the outbox without the adapter's positional transaction context. Each event is attributed to the authenticated caller and, like the content write paths, offers the fast-path drain and a bounded retention prune after commit — including on self-registration — so delivery and outbox pruning do not wait for the scheduled drain. The delete event reads the removed account's identity inside the delete transaction, so a concurrent update cannot make it report a stale address. The payload is PII-safe: identity only (id, email, name), never the password hash, a token, or role assignments.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.47
AlphaReleased 17 packages at 0.0.2-alpha.47 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #404 `f41a985` Thanks @mobeenabdullah! - More groundwork for the upcoming field group storage migration: the rename plan is now derived from the database rather than from configuration, so a table named through
dbNameis found and left alone rather than renamed over. Nothing calls this yet, so there is no change in behaviour in this release.
- #408 `1448488` Thanks @mobeenabdullah! - Fixed component data teardown resolving table names case-insensitively on every database. On PostgreSQL, and on MySQL with
lower_case_table_names=0, two names differing only in case are two different tables, so a registered component whose stored name differed in case from a real table could have that other table's rows deleted. Whether two spellings mean one table is now read from the server rather than assumed, including that SQLite folds ASCII case only, soÄandästay distinct tables there.
Also more groundwork for the upcoming field group storage migration: the rename plan is now checked against what the database actually contains before anything runs, so a name already in use, a registry row whose storage or companion table is missing, or a half-applied rename that recorded progress cannot account for all refuse up front instead of failing partway through. That part is not called by anything yet.
- #382 `b448e6d` Thanks @mobeenabdullah! - Saving a translation could overwrite the original language.
nextly db:syncmarks a collection as localized in a separate process from the running app, so the app could show the language switcher before its translations table existed — and a translation saved in that window wrote over the original-language values and changed the entry's URL, while reporting success.
The translations table is now prepared during db:sync and during a dev config reload, for collections, singles and field groups alike. If it is still missing, a write in a non-default language is refused with a clear message instead of overwriting anything, and the same refusal now covers singles and embedded field groups rather than only collections.
Writing the default language before the table exists still goes to the main table as before. The one exception is content that was localized from the start, whose translatable values have never had a main-table column to fall back to: saving that while the translations table is missing used to fail with a database error, and now reports the same clear message as the case above.
Collections and singles that set a custom dbName are handled correctly here too; previously their translations table could be created against a table name that does not exist. And a database that is unreachable or refusing connections is no longer reported as a missing translations table.
- #401 `1e0ef91` Thanks @mobeenabdullah! - Stop a relationship from populating a row the caller may not read. A related
- row belongs to another collection and carries that collection's own read
- rules, but expansion selected it straight from its table and applied only
- field-level redaction — so a caller refused the collection outright still
- obtained its rows by populating a relationship that pointed at them.
The target collection's stored read rules are now evaluated for the caller before its rows are populated, on single reads, listings and nested hops. A refused target reads as an absent relationship rather than an error, so one unreadable reference does not refuse the whole parent read.
- #409 `d5568ff` Thanks @mobeenabdullah! - Consolidate the version-history reference access checks behind a single shared media/users read gate. Internal refactor with no behavior change: the media and users label lookups previously duplicated the scope-then-RBAC check inline, and now share one audited gate so every reference-resolution path stays access-checked.
- #406 `b7e334b` Thanks @mobeenabdullah! - Show linked entries and media by name in version history.
Previewing a past version or comparing two versions now shows relationship and upload fields by name: a relationship reads as the linked entry's title, and an upload as its filename with a thumbnail, instead of a bare id. Labels are resolved through the same access checks as a normal read, so a linked document you are not allowed to read stays shown as its id rather than revealing its title, and a many-relationship still shows the links the version actually held rather than the document's current ones.
- #410 `77fb550` Thanks @mobeenabdullah! - Polish version history for localized and restored content, and give the Schema Builder control over retention:
- - Filter version history by locale. The history panel now shows a language badge on each version and a locale filter (defaulting to all locales), so a localized document's history is legible instead of interleaved. The filter is added to both list surfaces (the REST route and the dispatcher) and hides automatically for non-localized documents.
- - Show restore lineage. A version created by restoring an earlier one now displays a "Restored from vN" chip on its row and in its preview, so a rollback is visible at a glance.
- - Set version retention in the Schema Builder. The versioning toggle's Advanced tab gains a retention control — keep all history, keep the default (50), or keep the last N per document — reaching parity with code-first
versions.maxPerDoc. The value persists through the builder's create/update endpoints and the committableui-schema.jsonmanifest.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.46
AlphaReleased 17 packages at 0.0.2-alpha.46 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #398 `4b46b5c` Thanks @mobeenabdullah! - Compare any two versions of a document in the admin history panel.
From a version's preview in the history panel, you can now compare it against the previous version or the current one. The comparison lays out what changed field by field: edited text reads inline with the added and removed words highlighted, changed values show their before and after, and list items and relationships are marked as added, removed, moved, or edited. A "Changed only" toggle, on by default, hides everything that stayed the same so the real differences stand out.
Available for both collection entries and singles on any document with versioning enabled. A comparison is always between two versions in the same locale.
- #403 `2685550` Thanks @mobeenabdullah! - Recover a version history that failed to refresh, without reopening the panel.
When the history panel cannot refresh its list (for example after the tab regains focus following a save made elsewhere), it keeps the loaded history on screen but holds back the "Compare with current" and "Load more" actions until it can confirm the latest version. It now shows a short notice with a "Try again" button, so a transient failure can be recovered in place rather than by closing and reopening the panel.
- #402 `b85b799` Thanks @mobeenabdullah! - More groundwork for the upcoming field group storage migration: a migration run now claims a durable lock row for its duration, so a second run refuses instead of starting alongside it, and records a step only after checking the database reached the state that step intended. Nothing calls this yet, so there is no change in behaviour in this release.
- #399 `831cf74` Thanks @mobeenabdullah! - Internal groundwork for the upcoming field group storage migration: durable progress tracking, and a startup guard that refuses to serve a database whose storage state cannot be accounted for. Nothing calls this yet, so there is no change in behaviour in this release.
- #383 `5154cc2` Thanks @mobeenabdullah! - Plugin-contributed field types can now state rules about their own declaration, not just about stored values.
PluginFieldType.validateOptions(field)runs on every path a declaration reaches storage by — boot,db:syncand its watcher, Schema Builder writes, the direct create/update endpoints,nextly build,migrate:create, and the HMR reload — and returnstrue, a message, or a list of issues naming the options at fault. Each of those sits after the field-type registry is populated; thedefine*calls do not, so a custom type is still refused there as an unknown field type. It reads the declaration as written, which on the Builder path means the submitted payload rather than the parsed copy, since that is what gets persisted.
Options a plugin field type declares now survive the Schema Builder. The admin rebuilt each field from a fixed list of known properties, so a custom option was dropped on the way in and again on the way out: saving an unrelated setting erased it from a field the user never touched, and a type that requires the option would have refused every save.
A config edit that arrives while a reload is already running is now read. Reloads still never overlap, but the one in progress may have read the file before the edit landed, so the edit was previously dropped until the next save or a restart. A config load that fails now also leaves the field-type registry as it found it, instead of leaving it empty for whatever keeps running on the previously-loaded config.
Without it a custom type's options were accepted unread, so a declaration that no value could ever satisfy was only discovered per write, which reports a schema defect to the writer who cannot fix it. A disabled plugin's declaration checks no longer run, matching its validate.
nextly build now runs the comprehensive config validators over singles and components, not collections alone. A single or component whose declaration was invalid previously reported a clean build and failed later at runtime.
- #384 `d2dabb9` Thanks @mobeenabdullah! - Populate relationships that point at several collections. A field declared with
- a list of targets stores its value as a
{ relationTo, value }pair, and - expansion treated that pair as if it were a plain id while resolving the table
- from the field's first declared target. The resulting query bound an object
- where the driver expected a string, failed, and the failure was discarded, so
- the field came back as its raw pair at every depth with nothing logged.
Values are now loaded from the collection each one names, on single reads, listings and nested hops alike, and a populated row is redacted by that collection's own field rules.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.45
AlphaReleased 17 packages at 0.0.2-alpha.45 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #389 `0c79043` Thanks @mobeenabdullah! - Field group REST endpoints moved from
/api/componentsto/api/field-groups, and the route re-exports fromnextly/api/componentsandnextly/api/components-detailtonextly/api/field-groupsandnextly/api/field-groups-detail. Apps that re-export these handlers must rename their route files and imports; the old paths are removed rather than aliased.
- #388 `711e0c5` Thanks @mobeenabdullah! - Generated types now use the Field Group vocabulary:
nextly generate:typesemits<Slug>FieldGroupinterfaces and aConfig.fieldGroupsmap, and the Direct API exposesFieldGroupSlugandDataFromFieldGroupSlugin place of theirComponentequivalents. Re-runnextly generate:typesafter upgrading so the generated file and these types agree.
- #392 `b51f4e8` Thanks @mobeenabdullah! - The admin panel now calls reusable field structures Field Groups. They live at
/admin/builder/field-groups(previously/admin/builder/components), and the navigation, dashboard tile, builder and list screens use the new wording. Bookmarks to the old admin URLs will not resolve.
- #386 `2eeef30` Thanks @mobeenabdullah! - Reusable field structures are now called Field Groups.
defineComponent()becomesdefineFieldGroup(), thecomponent()field helper becomesfieldGroup(), thecomponentsconfig key becomesfieldGroups, and plugins contribute them viacontributes.fieldGroups. The old names are removed rather than aliased, so configs must be updated on upgrade.
Stored data is untouched: tables, columns and the JSON written for existing content keep their current names, so this release moves no data and needs no migration.
Configs and plugins still using the old key now fail at startup with a message naming the new one, rather than starting up with those definitions silently unregistered.
- #390 `768bdc7` Thanks @mobeenabdullah! - The Direct API namespace
nextly.components.*is nownextly.fieldGroups.*, and the dashboard, plugin admin metadata, and plugin introspection responses report field groups under afieldGroupskey. Reading the old namespace now reports the rename instead of failing as an undefined property.
- #397 `663306a` Thanks @mobeenabdullah! - Internal modules and services for reusable field structures now use field-group naming. This renames three container keys reachable through the exported
getService():componentRegistryService,componentSchemaServiceandcomponentDataServicebecomefieldGroupRegistryService,fieldGroupSchemaServiceandfieldGroupDataService. The old keys are not aliased, so a call using one no longer resolves. The field group schema service also dropsgenerateSchemaCode(), an unused generator that was reachable through that same accessor. Stored data, table names, config keys and HTTP routes are unchanged.
- #385 `d135685` Thanks @mobeenabdullah! - fix(nextly): make version snapshots complete and safe to restore
Several version-capture gaps that could lose or corrupt content on restore are fixed:
- Restoring an old version captured the content it applied but not the content it replaced, so content written while versioning was off (held in no version) was destroyed on restore. The current document is now snapshotted as a "Before restore" version inside the restore transaction, protected by the existing retention logic. This covers both collections and singles.
- A single's component snapshots stored relationship and upload fields expanded into whole related rows instead of reference ids, so a versioned single with a component relationship could not be restored (the write failed) and could leak the related row's fields past redaction. Component snapshots now store references only.
- For a localized, status-bearing single restored at a non-default locale, the pre-restore snapshot recorded the main row's status instead of that locale's, so undoing a restore could publish content that was never published. The snapshot now records the restored locale's own status.
- A localized single's snapshot recorded only the fields a partial edit touched, dropping the write locale's other, still-persisted translations. The snapshot now carries the full set of the write locale's translations.
- Publishing every locale of a localized entry emitted only a single, document-wide entry.published, so a subscriber watching one language never heard its translation go live. Each companion locale that actually transitions to published now emits its own locale-tagged entry.published. The publish is also judged against the row read under its transaction lock, so it records nothing when the entry was deleted concurrently.
- #391 `962fd25` Thanks @mobeenabdullah! - Add a version comparison (diff) engine and endpoint.
You can now compare any two saved versions of a collection entry or a single and get a typed, field-by-field diff: word-level text changes, added, removed, moved, and edited items in repeatable and component fields (matched by their stable id, so inserting one row no longer marks every row after it as changed), and added or removed relationship targets. The diff is computed on the server and is access-gated and field-redacted exactly like reading a version, so a field you cannot read never appears in a diff. It is reachable over the dispatcher and as a standalone nextly/api/versions-diff route for both collections and singles. The admin comparison UI follows in a later change.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.44
AlphaReleased 17 packages at 0.0.2-alpha.44 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #374 `a44ab69` Thanks @mobeenabdullah! - Component tables are always derived from the component slug, resolved through a single canonical path. A custom
dbNameis no longer accepted ondefineComponentorcomponents.create(): it could name storage the component does not own, and whether two spellings refer to one table depends on database server configuration rather than anything the config can state. Components that relied on it should drop the option and let the table name derive from the slug.
- #380 `90108db` Thanks @mobeenabdullah! - Relationships nested one level deeper now expand for collections you defined in code, not only for those created in the Schema Builder.
?depth=2 promises to populate a related document's own relationships, and it did so only for Builder-created collections. Resolving a target collection's fields read one of the two shapes those collections are stored in, so a code-first target resolved to nothing, the recursion guard failed, and the second hop was skipped silently at any depth — you got a bare id where a document was promised.
Two consequences, both now closed. A depth: 2 read returns what it says it returns. And an access rule reading across two hops — data.author?.organization?.suspended !== true — was enforced on a Builder collection while being quietly unenforced on a code-first one, so the same rule over the same data gave different answers depending on how the collection happened to be defined.
Worth knowing if you use code-first collections with chained relationships: reads at depth 2 or more will now issue the queries that second hop requires, where previously they stopped early. Depth still bounds the walk, and a field's own maxDepth still overrides it.
- #379 `655532d` Thanks @mobeenabdullah! - Plugin-contributed field types can now validate what they store.
PluginFieldType.validate(value, { data, req, field, path, mode })returnstrue, a message, or a list of issues with their own paths. Previously a custom type could be invented but say nothing about what belonged in it.
Values of a custom type are now also checked against the storage primitive the type declares. A number-backed type used to accept the string "3" on its way to a numeric column, because the built-in rules only ever matched built-in type names; they now run first, then the type's validate, then the field's own. A disabled plugin's field types keep their schema but no longer run their validate, matching how every other plugin behavior is skipped.
json fields now reject a value JSON cannot represent — a cycle, a BigInt, a bare function — as a validation error naming the field, instead of letting it reach the driver and fail there as a server error. Values JSON merely reshapes, such as an undefined member, are still accepted. contributes.fieldTypes is documented for the first time.
- #367 `66053c3` Thanks @mobeenabdullah! - Honour a Single's declared hooks and field defaults
Two documented parts of the defineSingle() config silently did nothing:
- Hooks — hooks: { beforeRead, afterRead, beforeChange, afterChange } were
never registered, so none of them ran. They now register (via the scaffolded
init helper, alongside collection hooks) and execute on the single read and
update paths. beforeRead remains side-effect-only, matching collections.
- Field defaults — a defaultValue on a Single's field never applied; the
first read auto-created the row with null in every defaulted column, because
a function defaultValue cannot survive serialization to dynamic_singles.
Defaults are now resolved from the live code-first config, so a scalar or
structured (group/repeater) default lands on the auto-created document.
- #366 `ee20d18` Thanks @mobeenabdullah! - Singles now get their storage table on MySQL, and on any app configured with only a
DATABASE_URLrather than an explicitDB_DIALECT. The DDL for a Single's table was generated from an optional environment variable that defaults to PostgreSQL instead of from the database the statements were about to run against, and a declaredslugfield was emitted as a type MySQL cannot put a unique index on, so the table was never created and the first read reported it missing.
The plugin test harness can also boot against a real database: createTestNextly({ dialect: "postgresql" | "mysql" }) creates a dedicated database for that instance and drops it on destroy(), and getConfiguredTestDialects() reports which dialects the environment is configured for so a suite can cover those and skip the rest. The default is unchanged: in-memory SQLite.
- #381 `22b43f2` Thanks @mobeenabdullah! - Updating a Single no longer returns related fields the writer is not allowed to read.
The response expands relationships, and those rows belong to another collection carrying its own field-level access.read rules. Read paths have evaluated them since the field-access work landed; this path forwarded no caller, so it returned every related field intact — including ones the same caller's GET would withhold. That made the write path a way around the rule: write anything, read the response back.
A writer supplied a relationship id, not the related row's protected fields, so "they supplied the data" does not cover them. The rule that applies is the target collection's own, and it is now evaluated against the caller that made the write.
This reaches every hop the response expands, not only the first: a related row's own relationships carry the rules of the collection at the far end, and those are evaluated too.
Every caller the access gate applies to is judged, including one with no identity — an anonymous write permitted by a public update rule gets the same answer its read would give. Only a trusted write bypasses this, through overrideAccess rather than through an absent user.
One consequence worth knowing if you write afterUpdate hooks: they receive the response as the caller will see it, so a related field that caller may not read is already gone. That matches how reads behave — related-row rules are applied while relationships expand, before afterRead hooks run — and it is why the two paths now agree. The Single's own fields are still redacted after your hooks, unchanged.
- #369 `f822937` Thanks @mobeenabdullah! - A read that cannot assemble the evidence its access rule needs is now refused rather than allowed. This covers relationships nested in a group or repeater, and counts references as well as checking them: a
hasManyexpansion drops the entries it could not fetch, so a list that came back shorter is evidence that went missing, not evidence that nothing is there. A relationship configuredmaxDepth: 0is left alone, since an unexpanded reference is what that asks for, and so is one declared with the legacyrelationtype, which is never populated at all. Localized references are checked too, by recording what the document referred to once translations were overlaid and before anything was expanded — which is the only point a localized reference is visible at all, and makes the count of what came back comparable for those fields as well. Upload references are held to the same bar as relationships. A relationship pointing at several collections is left alone: it is stored and served as a reference rather than populated, so demanding a document there would refuse every read of a Single that has one.
Translation loading fails the read rather than reading through it. A companion query that errored was previously swallowed, leaving the main row's value in place — which a rule cannot tell apart from a translation that says so.
A relationship that exists only inside a group or repeater is now expanded on read. The check for whether a Single had any relationships to expand looked at top-level fields only, so a schema that nests all of them was returned with bare ids. Reaching into containers is opt-in, and the write-response path does not opt in: it threads no caller, so the target collection's field rules cannot be evaluated for it and the rows it pulled in could not be redacted. Expansion is best-effort by design — a related table that cannot be read yields the bare id — which is right for a response and wrong for a document about to be judged: a rule written as data.author?.suspended !== true reads the missing row as permission. Every stored reference a rule may inspect is checked to have become a row before the rule is asked, and a read whose evidence is incomplete fails instead.
A Single deleted while it is being read no longer materializes defaults nobody authorized. The rule approved the stored row; if that row disappears before the read fetches it again, what would be created is a default document no rule has seen. It is judged before it is written, rather than persisted — with its localized defaults and first version — and refused afterwards.
The depth an access rule sees no longer drops below an ordinary read's. A caller asking for depth: 0 narrows their response, and the authorization view now expands at least as far as an unqualified read would, and further when the caller asked for more.
Access callbacks can no longer write through a Map or Set in their argument, including through an object used as a Map key. They already received plain objects and arrays as copies; these were passed by reference, so a callback could change the payload it was only asked to judge. Data that refers to itself is copied without recursing forever, and a value reachable by two paths stays one object in the copy.
A ?depth=0 read still gets the references it asked for. The response deliberately leaves relationships unexpanded at that depth, so holding it to "every reference became a document" would refuse exactly what was requested; the authorization view judges those relationships at the full read depth regardless. Uploads are unaffected, since they populate at any depth.
The decision made on the document you actually receive is held to the same completeness bar as the earlier one, so expansion that succeeds before your hooks run and fails after cannot leave a rule deciding on a reference where it expects a document. That check runs on the assembled document, before your afterRead hooks shape it — a hook is free to drop or replace a relationship, and nothing tells that apart from an expansion that failed.
A read refused for incomplete evidence reports the canonical internal error rather than the underlying failure's own message, which for a database fault is schema detail. That covers relationship expansion, component population, translation loading and the per-locale overview alike.
A group or repeater whose stored value cannot be read — malformed JSON, valid JSON of the wrong shape such as a list where the field declares a group, or a repeater row that is not a row — now fails the read rather than being treated as empty, which would have walked past every relationship inside it.
Translation loading and the per-locale overview both fail the read when it is being judged, rather than leaving the fields off. An ordinary read is still served best-effort; a rule cannot tell "no translations" from "the query failed", so a read about to be judged gets the failure instead.
Metadata attached to a Map or Set under a symbol key survives the copy handed to an access callback. Arrays keep their holes and their own properties when handed to an access callback, under the keys they actually have — a decoration like "01" no longer overwrites element 1. Sparse arrays keep their holes, and a Map or Set carrying its own properties keeps them, so a rule reading either decides on the structure the payload actually has.
A subclass of Map or Set reaches an access callback as itself rather than rebuilt as the base collection, which would have discarded its methods and private state.
- #375 `0febd62` Thanks @mobeenabdullah! - Collections and Singles created in the Schema Builder can now opt out of webhook recording from their Advanced tab, so content holding personal data never reaches the outbox or any subscribed endpoint. The setting is stored on the entity, takes effect on the next write, and survives restarts. Existing installs should run
nextly migrateto add the new registry column; until then the switch has no effect and recording continues as before.
- #378 `0498e02` Thanks @mobeenabdullah! - feat(nextly): capture versions on programmatic entry writes
The tx-API and batch entry writes (createEntryInTransaction, updateEntryInTransaction, and the createEntries/updateEntries batch internals) now record a durable version snapshot and carry the full relational document (component subtrees and many-to-many relations) on their outbox event, matching the interactive create/update paths. Programmatic writers (importers, plugins, agents) previously left no version history and emitted parent-columns-only events.
- #377 `3785345` Thanks @mobeenabdullah! - Programmatic entry writes now emit webhook events. Writes through the transaction API (
createEntryInTransaction/updateEntryInTransaction), the batch helpers (createEntries/updateEntries), andpublishAllLocalespreviously recorded no webhook events, so importers, agents, and plugins writing through them were invisible to webhook subscribers. These paths now recordentry.created/entry.updatedand the correspondingpublished/unpublished/status_changedlifecycle events inside the write transaction, so an event is delivered for every entry write and is never emitted for a write that rolls back.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.43
AlphaReleased 17 packages at 0.0.2-alpha.43 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #368 `648c7f4` Thanks @mobeenabdullah! - The admin's design tokens now actually drive its appearance. Setting
-
--radius,--font-sansor a brand colour reaches the components that - should follow it, so a themed admin looks themed instead of only partly so.
- Radii across inputs, buttons, cards, badges and panels are derived from
-
--radiusrather than fixed per component, and the font family tokens are read - at their use sites rather than being frozen into the compiled stylesheet.
Font weights work again. font-bold, font-semibold, font-medium and
font-normal had been compiling to nothing, so headings, buttons and emphasis
rendered at the body weight throughout the admin; they now render at their
intended weight.
Several colour bugs are fixed, mostly in dark mode: sidebar navigation labels no longer take a tint from a themed brand colour, the sidebar has a distinct resting and active ink step, the email template preview frame no longer paints a white box on a dark page, and floating panels, neutral washes and the draft swatch are tinted from tokens instead of hardcoded values.
Borders are lighter. --nx-border is now a decorative separator, so tables,
cards and dividers read as quiet rules rather than hard lines, while form
controls keep a clearly visible edge: text fields, search fields, selects, the
tag, code and rich-text editors, colour pickers and the date-picker trigger are
all drawn with the control-boundary token.
Radio buttons and avatars are round again, along with switches, spinners and
status dots, which a non-zero --radius had been squaring off.
- #361 `7d5a62d` Thanks @mobeenabdullah! -
nextly migratecan be run more than once against MySQL and PostgreSQL. The core schema comparison read several dialect spellings of the same value as differences — MySQL booleans, itsnow()/CURRENT_TIMESTAMPdefaults, and PostgreSQL serial sequence defaults — so a second run reported changes to the schema the first run had just written and refused to proceed. Anextval()default over any sequence other than the one its column owns is still reported as a change.
- #306 `6481791` Thanks @faisal-rx! - Duplicate entries now report "Resource already exists." instead of the stale-version conflict message, and CLI guidance only suggests commands and flags that exist.
Creating an entry that violates a unique constraint returned 409 with "The resource has changed since you last loaded it. Please refresh and try again." — the message for an optimistic-concurrency conflict, which wrongly tells the user to refresh. The legacy service envelope now carries the canonical error code, so the REST dispatcher and the Direct API rebuild the precise DUPLICATE error.
CLI guidance is corrected to real commands: the production auto-sync guard points at nextly migrate:create + nextly migrate (previously the unregistered migrate:generate / migrate:run), nextly add no longer tells you to run the removed nextly dev, and the db:sync --force help text states the flag is a deprecated no-op. nextly upgrade and nextly migrate:resolve now accept --force-unlock, so the migrate-lock busy error's advice to re-run with that flag works on every command that takes the lock.
- #350 `ac3afca` Thanks @mobeenabdullah! - A field's declared constant
defaultValueis now applied when a collection entry is created through the REST or Direct API, not only in the admin form, and a required field carrying one can be created without supplying it. Defaults reach nested group and repeater fields too.
Two limits: a defaultValue written as a function is not applied on these paths, because the stored collection definition cannot carry a function, and bulk or caller-managed transactional creates are unchanged.
- #365 `55bc36e` Thanks @mobeenabdullah! - Code-first collections now get their tables created at boot on MySQL. The boot-time schema sync goes through an entry point that was handed a database connection rather than a connection URL, and drizzle-kit needs the MySQL database name as a separate argument, so the apply failed and the first query against the collection reported a missing table. The name now comes from the connection itself, which also fixes the publicly exported
applyDesiredSchemafor MySQL callers.
- #359 `732eb44` Thanks @mobeenabdullah! - A single polymorphic relationship (one whose
relationTolists several collections) is now recognised as a JSON-backed field on the write path, matching how upload fields with the same shape are already treated. Its value reached the driver unserialized before, so writing one could fail.
- #360 `c7a3843` Thanks @mobeenabdullah! - Retire the insecure
webhook-notificationprebuilt hook
The webhook-notification prebuilt hook (selectable in the Schema Builder's
Hooks editor) delivered over a bare fetch with no SSRF protection, and its
secret produced a base64 of the payload rather than a real HMAC. A signature
that is not an HMAC gives a false sense of authenticity, so the hook is removed
rather than left in place.
Use Nextly's signed webhook system instead: add an endpoint under Webhooks in the admin. It delivers HMAC-signed, SSRF-guarded requests through the delivery engine.
Migration: any collection that still has a stored webhook-notification hook
degrades to a no-op after upgrade (the write path skips unknown hook ids and the
admin hides the missing card), so content keeps saving. Re-create the
notification as a Webhooks endpoint to restore delivery.
- #304 `051f660` Thanks @faisal-rx! - The rich text editor now follows content-language switches and version restores.
Lexical reads its initial state once at mount, so when a localized entry or single switched language the form fetched and reset the other language's values, every regular input followed, and the editor kept displaying the first-loaded language. Stored translations were correct in the database, but the editor showed the default language for every locale, and saving from that stale screen overwrote the open locale's translation with the displayed content.
A sync plugin now loads external form-value changes into the editor: a language switch or version restore replaces the editor content, an untranslated language shows an empty document, and the editor's own keystrokes echoing back through the form are recognized and left alone so the caret never jumps while typing. The undo history is cleared on each external load so undo cannot resurrect the previous language's document into the current one.
- #354 `0c2c369` Thanks @mobeenabdullah! - Custom read rules are now enforced on Singles. A Single you restricted with one was previously readable by anyone who could reach it, because the rule was never consulted.
The rule is judged against the document you actually receive: translations resolved for the requested language, component data attached, relationships expanded. A rule reading data therefore sees the finished document rather than a partial row, which is what makes a rule such as data.secret !== true mean what it says.
That decision is made before your hooks run and before a Single is materialized on first read, so a caller your stored data refuses reaches neither. The document is assembled twice for a restricted Single: once to decide, and once for the response after your beforeRead hooks have had their turn.
The rule is then asked again about the document being returned, because a hook may have changed it. One consequence is worth knowing: if a hook is what creates the denial — it sets the very value the rule refuses — that hook has necessarily already run by the time the rule can see its effect. The earlier decision covers every refusal your stored data supports; it cannot cover one that does not exist until user code produces it.
Rules that return a query constraint are refused on Singles rather than partly applied. A constraint narrows a result set; by the time the read is decided, a Single's document has been assembled from several tables and no longer corresponds to one row for the database to test the predicate against. Return a boolean from a Single's read rule; constraints continue to work on collections, where they are folded into the query.
A rule that returns no decision at all now denies, on collections as well as Singles. A rule is free to fall through without returning, and such a result was previously read as "allowed, with nothing to filter by" — admitting the caller and narrowing nothing.
Field-level read access is applied to a Single after the read is decided, not before. A field your rule inspects is no longer removed from the document the rule is shown, so a rule guarding a value the caller may not read decides on that value rather than on its absence.
Ownership is always decided against the stored row, and against the row actually being returned. An owner-only Single is not judged on the response object, which an afterRead hook or a field read rule is free to strip the owner identifier from — a transformation that could refuse a document to its real owner. It is judged on the row read before your hooks and again on the row read after them, so a hook write or a concurrent owner change cannot hand back a document the caller no longer owns.
A first read of a Single that has never been written is judged against the defaults it would create, so a rule that refuses those defaults no longer lets the read materialize the document (and its first version) before returning 403.
Your own claims on a user now reach the access rules, on every transport. A custom rule reading a tenant, a plan or an entitlement saw undefined, because the caller was rebuilt from a fixed list of canonical fields at four separate layers: the Direct API namespaces, the collection access service, the Single access gate, and the REST route-auth boundary. A rule written to refuse a caller therefore admitted it. Custom JWT claims are now carried from the verified session through to the rule, and the Direct API's UserContext accepts them explicitly, along with roles for rules that decide on more than one. A claim can never displace the authenticated identity: id and roles come from what the route authenticated, not from what the token says about itself.
A read rule whose exclusion list comes back empty no longer denies everyone. { id: { not_in: [] } } excludes nothing, so it restricts nothing — but it translated to no SQL condition, and a constraint that narrows nothing is refused rather than allowed to widen a read. Members that cannot narrow anything are now removed before that judgement, so the rest of the rule is what decides, and a rule made up entirely of them permits the read. An empty in list is still refused: it should match nothing, and honouring it after translation dropped it would widen the read to every row.
Relationship depth no longer changes who is allowed to read. ?depth=0 shapes the response, and letting it shape the authorization view too gave a caller a way to blind a rule: the relationship stayed an id, so a rule reading into the related row saw nothing and read that as permission. Authorization uses the full read depth whatever the caller asked for.
Field-level access.read callbacks are handed a detached copy too, and so are field write callbacks. They run after the document-level decision, so a callback that reached into a shared group, repeater or component could change a document that had already been authorized — with nothing to judge it again. The copy is taken before nested fields are redacted, so a rule at the parent level still sees what the document held when the pass began rather than what an earlier-registered field's redaction left behind. Values that cannot be structurally cloned — a JSON prop defining toJSON(), for instance — are passed through rather than rejected, so isolating the snapshot never fails a valid write.
findSingle and findSingles forward your fallbackLocale. It was dropped, so a no-fallback read still fell back to the default language through the Direct API, and a rule keyed on it saw undefined.
An access rule that writes to its data argument no longer changes the response. Rules are handed a detached deep copy, so a rule remains a decision rather than a transformation — a shallow one still shared every component, repeater and expanded relation with the response — and password values are stripped after every callback that could reintroduce one.
A rule that reads an expanded relationship now sees the related row as stored, not as the response will show it. Related rows are redacted against the target collection's own field rules, and doing that before the decision handed the rule the hole rather than the value, so data.author?.suspended !== true read undefined and admitted a caller the stored data refuses. The response is still redacted; only the decision sees through it.
A draft Single stays hidden from an untrusted caller even when a stored rule would refuse them. The rule was decided before the draft/published filter, so the answer was 403 rather than the 404 that conceals a draft — which disclosed both that the row exists and what the rule made of the caller.
- #371 `b8bf6d4` Thanks @mobeenabdullah! - Media cards keep their metadata inside the tile, four more surfaces stay within their rounded corners, and the corner-radius guide now matches the components.
In the media library's grid view, a card's file size could paint outside the card border while its dimensions were squeezed to nothing. At the six-column layout this left the dimensions reading as a stray "1..", and a long label such as "Invalid size" spilled past the tile edge. The size is now always readable inside the card, and the dimensions appear once the card is wide enough to render both values in full, with a tooltip on the row carrying both at any card width.
Four surfaces painted a full-bleed child square across a rounded parent, which anyone running a nonzero --radius could see: the email-template segmented control, the component-row card header, the schema-builder field table header, and the code editor's validation error strip. CardHeader also gained the top-corner counterpart of the fix CardFooter already carried. All of these are unchanged at the shipped --radius: 0.
The slash command menu in rich text fields declared a stacking order that never took effect, so it could be covered by a dialog. It now sits above one.
The corner-radius tier tables in the theme and in the plugin authoring guide described a system the components do not implement, pointing plugin authors at the wrong step for alerts, table wrappers, checkboxes, icon buttons, switches and tabs. Both now agree with the code and with each other, they no longer offer rounded-xl and rounded-2xl as steps of the radius knob (the published Tailwind preset never exported them, and they do not go square at --radius: 0), and they state what --radius: 0 actually resolves to for each step. A new test pins the contract so the documents and the components cannot drift apart unnoticed.
- #363 `8de5ea3` Thanks @mobeenabdullah! - Content-route reads now enforce publish state and access, and localized draft translations no longer leak.
resolveContent and createContentRoute (from nextly/runtime) now default to reading only status: "published" through the lifecycle-aware publish filter, so for a localized collection a draft translation under a published main row is no longer returned. They also enforce the collection's read-access rules by default: a rule-less (public) collection still renders, but a collection with a stored member-only or role-based read rule is hidden from an unauthenticated request (it resolves to notFound()). Pass a user to render member content, or overrideAccess: true for a fully trusted read.
The Direct API find gains a status?: "published" | "draft" | "all" option that drives the same lifecycle-aware filter (constraining a localized collection's per-locale companion status), replacing the previous statusField where-clause on the content-route helpers. Status-less collections are handled automatically — the scope is a no-op there.
- #355 `521e453` Thanks @mobeenabdullah! - nextly now ships content routing and sitemap/robots delivery from
nextly/runtime:resolveContent(F1-cached published-by-slug lookup that rethrows on a transient error),createContentRoute(an optional catch-all factory that resolves any path to a published entry, withgenerateStaticParams,generateMetadata, and a reserved-path denylist),isReservedPath, andnextlySitemap/nextlyRobotsfor the canonicalapp/sitemap.tsandapp/robots.ts.cachedFindnow runs the read UNCACHED (instead of throwing a framework invariant) when called outside a Next request/build scope, so content reads work in tests, scripts, and other non-request contexts.
- #362 `0da91f5` Thanks @mobeenabdullah! - Add the
nextly webhooks:prunecommand
nextly webhooks:prune runs a webhook-queue retention pass on demand (with
--dry-run), so a self-hosted install can reclaim the fanned-out event ledger
and terminal delivery log from a cron job. It reads the same webhooks.retention
policy as the automatic passes and does nothing when retention is disabled. See
the new "Webhook queue retention & VACUUM" guide.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.42
AlphaReleased 17 packages at 0.0.2-alpha.42 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
- #332 `80febb5` Thanks @mobeenabdullah! - Block props now go through the field system: a block declares its editable props with the same field types a collection uses, and their values are validated by the same server-side pass entries get. Binding a data field to a block prop is derived from the prop type, so every compatible prop offers it without the block opting in.
- #343 `a614d3d` Thanks @mobeenabdullah! - Collections and singles can now hold a page built from blocks. Add a field with
blocks({ name: "content" }), optionally naming which registered blocks it accepts, and the whole page document is stored in one column and typed for you when you generate types.
- #346 `4f39297` Thanks @mobeenabdullah! - Field-level read rules now reach related rows inside components. A component's relationship fields copy whole rows out of the collection they point at, and neither the parent entity's field list nor the component's describes that collection's fields, so a field you protected there was returned inside the populated component to any caller that could read the parent. Reading a collection entry or a Single now judges those related rows by the rules of the collection they come from, for the caller making the request.
This completes the read side of the redaction added for direct relationships. Write-side callers that assemble a payload without a caller are unchanged, so a mutation response still returns what it did before.
- #347 `81204af` Thanks @mobeenabdullah! - Outbox event recording is now endpoint-gated. A content write records a webhook event only when the install has at least one enabled webhook endpoint, or when the new
webhooks.auditoption is turned on. Installs with no webhooks configured no longer pay an event-table insert and a full-document serialization on every write.
Recording resumes immediately when an endpoint is created in the same process, and within about 30 seconds for one created in another process. A few events may still be recorded just after the last endpoint is removed; retention prunes them.
- #344 `2dec172` Thanks @mobeenabdullah! - The scaffolded blog template now uses tag-based ISR: publishing or editing content in the admin refreshes the affected pages on the next request, with no rebuild and no 60-second timer. Content-template scaffolds (blog) also install
nextlyand@nextlyhq/*from thealphadist-tag so they always get thenextly/runtimecache helpers the pages use.
- #339 `9ab5f19` Thanks @mobeenabdullah! - Content pages can now use tag-based ISR: cache a read with
cachedFindand tag it withnextlyTagsfromnextly/runtime, and every content change (create, update, publish, unpublish, delete, or slug rename) busts exactly those tags so the page regenerates on the next visit — no rebuild, noforce-dynamic. Revalidation turns on automatically wherever you mount the admin route (createDynamicHandlers). A per-operationdisableRevalidateflag lets a bulk import, seed, or CLI write skip it. See the new "ISR and caching" guide, including the rule for keying a per-user read so it cannot leak across callers.
- #337 `6f19e60` Thanks @mobeenabdullah! - Collections and singles created in the Schema Builder now carry their cache-revalidation setting. A new "Cache revalidation" switch on the Advanced tab (on by default) lets you opt a collection or single out of busting cache tags on write, and the setting round-trips through boot, HMR,
db:sync, andmigrate:createthe same way code-firstrevalidateconfig does. Existing databases pick up the new registry column when you runnextly migrate(boot warns until it is run).
- #336 `d0a45d5` Thanks @mobeenabdullah! - Collections and singles can now opt out of webhook recording with
webhooks: false(or{ record: false }). Form submissions opt out by default, so visitor IP address, user agent, and submission content are no longer recorded to the webhook outbox or delivered to endpoints subscribed toentry.createdor*. Existing installs: submission events recorded before this release remain in the outbox and can be pruned manually; no data is deleted automatically.
- #351 `b44b1a3` Thanks @mobeenabdullah! - @nextlyhq/plugin-seo now generates a sitemap of your published content and serves it at a public HTTP route under Nextly's dynamic handler (in a scaffolded app,
/admin/api/plugins/@nextlyhq/plugin-seo/sitemap.xml). It lists one URL per published entry across the collections you configure, reflects publishes and edits on the next request, and leaves out drafts and any page markednoindex. Configure the site origin withbaseUrland per-entry paths withurlFor, or disable the route withsitemap: false.
- #348 `c0b3796` Thanks @mobeenabdullah! - Read rules that narrow by a filter are now applied in full. A stored read rule can return a filter describing which rows the caller may see, and only part of it was being applied: the first field's
equalsvalue. A rule naming two fields filtered by one of them, a rule using any other operator applied nothing at all, and a rule whose value was legitimately falsy —0,false, an empty string — also applied nothing. In each of those cases the read returned rows the rule was written to exclude, and the matching count reported them too.
Filters now go through the same translation your own where clauses use, so every field and every supported operator binds. Owner-only rules are unaffected: a single non-empty owner id was the one shape the old path handled correctly, which is why this went unnoticed.
A filter is applied only if all of it can be applied, and access filters are held to a narrower shape than the where clauses you write yourself. A filter may name columns on the collection (or its localized fields) and compare them with any supported operator, including the shorthand { field: value } form. Logical and/or groups, dotted paths like author.name, and empty in/not_in lists are refused rather than approximated, because each of those translates to something narrower than the rule states — or, in the dotted case, to a comparison against a different column.
A refused filter is reported as forbidden, and the matching count refuses identically. If you need a shape that is currently refused, the read fails closed instead of quietly returning more than the rule allows.
- #335 `8512d5d` Thanks @mobeenabdullah! - Field-level read rules now apply to related rows. Populating a relationship copies the whole related row into the parent entry, and a field's
access.readwas only ever evaluated against the collection being read, never against the collection on the other end of the relationship. A field you protected on one collection was therefore returned in full to anyone who reached it through a relationship from another, at any depth. Passwords and system columns were already stripped there; this closes the same gap for the rules you write yourself.
Each related row is now judged by its own collection's rules, for the caller making the request, so a relationship cannot return more than a direct read of that row would. Trusted server-side reads that pass overrideAccess are unaffected, and secrets are still stripped for every caller regardless.
If you relied on reading a protected field indirectly through a relationship, that field will now be absent: read it as the collection that owns it, with a caller its rule admits.
- #333 `ba3e8f4` Thanks @mobeenabdullah! - Collection read rules now apply over the REST API. Listing, fetching and counting entries previously ignored who was asking, so a collection configured with an owner-only or role-based read rule still returned every row to any caller who could reach the endpoint — the rule only ever held on writes and inside the Direct API. Reads now evaluate the caller against the collection's stored rules, with owner-only scoping applied in the database query so pagination and totals stay correct, and a count can no longer describe rows the caller is not allowed to see.
Role-based read rules are evaluated against the caller's resolved roles, and a super-admin keeps the bypass they already have everywhere else. A scoped API key is judged on its own read grant rather than on the permissions of the account that issued it, so a read-only key issued by an administrator is no longer treated as that administrator's full session.
If you configured a read rule expecting it to be enforced, this closes that gap. If instead something in your app depended on reads returning unfiltered data, it will now see only the rows its rule allows: check any integration that reads with a user session or API key against a collection whose read rule is not public.
- #356 `a24c17e` Thanks @mobeenabdullah! - REST reads now default to published-only. A list, get, or count request to a Draft/Published collection or single with no
?status=returns only published entries; pass?status=allor?status=draftto include drafts (subject to your read access rules). Previously these reads defaulted to returning every status, which could expose drafts to any caller.
An invalid ?status= value (for example a typo like ?status=pubished) is now rejected with a 400 instead of being silently treated as "all", so a malformed filter can never widen a read. Trusted server-side Direct API calls are unchanged (they still see every status). The admin panel already requests every status, so editors continue to see their drafts.
- #338 `1687ff1` Thanks @mobeenabdullah! - Read rules on Singles now apply over the REST API. A Single's stored read rule was enforced on every update and inside the Direct API, but reading the document over HTTP skipped it entirely, so a Single you restricted to a role was still returned in full to any caller who could reach the endpoint. Reads now evaluate the caller against the rule you configured, and a Single's related rows are redacted by the field rules of the collection they come from. Relationships reached through an embedded component are not yet covered.
A scoped API key is judged on its own read grant rather than on the permissions of the account that issued it, and super-admins keep the bypass they have everywhere else.
An owner-only read is judged against the document itself, since a Single has no list query to fold an ownership filter into.
custom read rules on Singles are not enforced by this change. A custom function may return a query constraint, which a list read compiles into SQL; applying that to a single document would mean re-implementing the filter grammar, so it is left as it behaves today rather than partly applied. public, authenticated, role-based and owner-only read rules are all enforced. That rule reports "allowed" for any authenticated caller and hands back the predicate a list query would have filtered by, which a Single has no list to apply, so the predicate is checked against the row instead.
The standalone nextly/api/singles-detail GET route is deliberately public and does not authenticate. A Single with no read rule stays publicly readable there, exactly as before. A Single you restrict is no longer served by that route at all, including to callers the rule would admit, because the route has no caller to evaluate. Read restricted Singles through the authenticated API instead.
If you configured a read rule on a Single expecting it to be enforced, this closes that gap. If something in your app read a restricted Single over HTTP and depended on getting it, that call will now be denied: give the caller a role the rule admits, or read it through the Direct API, which is trusted by default.
- #353 `48f82a8` Thanks @mobeenabdullah! - nextly now exports
buildMetadatafromnextly/runtime: it maps a content entry's SEO field group (from@nextlyhq/plugin-seo) to a Next.jsMetadataobject, so a page'sgenerateMetadatabecomes a single call instead of a hand-written mapping. It sets the title, description, canonical, OpenGraph, Twitter card, robots (fromnoindex), and hreflang alternates, with per-call fallbacks for blank fields. Thenextdependency is type-only, so importing it never forcesnextat load.
- #349 `9cab18c` Thanks @mobeenabdullah! - Add the first-party @nextlyhq/plugin-seo package. Register it in your config to add an SEO field group (title, description, OG image, canonical, noindex) to the collections you name. It is opt-in and framework-agnostic (no Next.js dependency), so it is safe in headless and admin-only projects.
The plugin SDK now also re-exports the field-authoring factories (text, textarea, checkbox, upload, group) and the FieldConfig type, so plugin authors get the whole authoring surface from @nextlyhq/plugin-sdk.
- #340 `3d48019` Thanks @mobeenabdullah! - Fixed a dev-mode gap where setting a collection or single to
webhooks: falsedid not take effect if the same config reload also hit a schema error (for example a transient database blip during introspection, or a change awaiting confirmation). The recording opt-out is now applied up front, so a newly private entity stops recording immediately even when the rest of the reload is deferred; re-enabling recording still waits for a clean schema sync.
- #341 `a98cdcf` Thanks @mobeenabdullah! - Fixed webhook-outbox retention not running after a Single update that opts out of both recording (
webhooks: false) and cache revalidation (revalidate: { disable: true }) when a post-commit hook then fails. The Single write result now carries an explicit committed-write signal, matching the collection path, so the write-path cleanup runs for every durable write on installs without a scheduled webhook drain.
- #342 `f0b4fc3` Thanks @mobeenabdullah! - A Single that opts out of webhook recording (
webhooks: false) no longer assembles its webhook payload on update. Previously the previous/next event documents were built (reading every component subtree) before the opt-out was checked, so a scalar update to an opted-out Single still performed webhook-only component reads and could fail on a missing or stale component table. The opt-out is now resolved before any payload assembly.
- #345 `38d50d0` Thanks @mobeenabdullah! - Collection status webhook events now fire. Publishing an entry delivers
entry.published(and the genericentry.status_changed); unpublishing deliversentry.unpublished(andentry.status_changed); any other status change deliversentry.status_changed. A create-as-published deliversentry.created+entry.published. Per-locale status changes on a localized collection are tagged with their locale. Every status event carries an explicitstatusChange: { from, to }. Only Draft/Published collections emit these, and collections that opt out of recording (webhooks: false) emit none. Previously these event types were subscribable in the admin UI but never fired.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
v0.0.2-alpha.41
AlphaReleased 16 packages at 0.0.2-alpha.41 in lockstep.
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport. - ### @nextlyhq/adapter-mysql
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport.
- Updated dependencies [`a4f503d`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.41
- ### @nextlyhq/adapter-postgres
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport.
- Updated dependencies [`a4f503d`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.41
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport.
- Updated dependencies [`a4f503d`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.41
- ### @nextlyhq/admin
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport.
- Updated dependencies [`a4f503d`]:
- - nextly@0.0.2-alpha.41
- - @nextlyhq/ui@0.0.2-alpha.41
- ### @nextlyhq/admin-css
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport. - ### @nextlyhq/blocks-engine
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport. - ### @nextlyhq/plugin-form-builder
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport.
- Updated dependencies [`a4f503d`]:
- - @nextlyhq/admin@0.0.2-alpha.41
- - nextly@0.0.2-alpha.41
- - @nextlyhq/plugin-sdk@0.0.2-alpha.41
- - @nextlyhq/ui@0.0.2-alpha.41
- ### @nextlyhq/plugin-page-builder
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport.
- Updated dependencies [`a4f503d`]:
- - @nextlyhq/admin@0.0.2-alpha.41
- - nextly@0.0.2-alpha.41
- - @nextlyhq/plugin-sdk@0.0.2-alpha.41
- - @nextlyhq/ui@0.0.2-alpha.41
- ### @nextlyhq/plugin-sdk
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport.
- Updated dependencies [`a4f503d`]:
- - @nextlyhq/admin@0.0.2-alpha.41
- - nextly@0.0.2-alpha.41
- ### @nextlyhq/storage-s3
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport. - ### @nextlyhq/ui
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport. - ### create-nextly-app
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport. - ### nextly
Patch Changes
- #328 `a4f503d` Thanks @mobeenabdullah! -
@nextlyhq/blocks-enginenow providesdefineBlockfor declaring a block type — its props, default styles, child slots, style capabilities, and how it renders — plus the registry that collects them when an app boots. Mistakes are caught at startup with a clear message instead of surfacing as broken pages: a duplicate block name names both sources, and bumping a block's version without providing the matching upgrade step is refused outright. Third parties can add new style capabilities throughregisterSupport.
- Updated dependencies [`a4f503d`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.41
- - @nextlyhq/adapter-mysql@0.0.2-alpha.41
- - @nextlyhq/adapter-postgres@0.0.2-alpha.41
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.41
v0.0.2-alpha.30
AlphaReleased all 12 packages at 0.0.2-alpha.30 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/plugin-sdk
Patch Changes
- #145 `76bde2a` Thanks @muzzamil-rx! - The API reference was not correctly specified in the
useEffectdependency array. It was set as[api], whereas it should have been[api.public].
- Updated dependencies [`76bde2a`]:
- - @nextlyhq/admin@0.0.2-alpha.30
- - nextly@0.0.2-alpha.30
v0.0.2-alpha.28
AlphaReleased all 12 packages at 0.0.2-alpha.28 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-mysql
Patch Changes
- Updated dependencies []:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.28
- ### @nextlyhq/adapter-postgres
Patch Changes
- Updated dependencies []:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.28
- ### @nextlyhq/adapter-sqlite
Patch Changes
- Updated dependencies []:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.28
- ### @nextlyhq/admin
Patch Changes
- #134 `0363799` Thanks @faisal-rx! - Remove the hardcoded default super-admin credentials from
seedSuperAdmin(). The seeder no longer falls back to a built-in email/password pair: callers (the/admin/setupwizard and the dev seed) must pass an explicitemailandpassword, and the function throws aVALIDATION_ERRORif either is missing.seedAll()likewise fails closed when super-admin seeding is enabled but no credentials are supplied, instead of creating a known-weak default account. This removes a well-known default credential from shipped framework source.
Also hides the placeholder address the admin user menu previously showed when a user had no email (the line is now omitted when empty), and standardizes example email placeholders across the admin and form-builder UIs onto the nextly.local domain.
- Updated dependencies []:
- - @nextlyhq/ui@0.0.2-alpha.28
- ### nextly
Patch Changes
- #134 `0363799` Thanks @faisal-rx! - Remove the hardcoded default super-admin credentials from
seedSuperAdmin(). The seeder no longer falls back to a built-in email/password pair: callers (the/admin/setupwizard and the dev seed) must pass an explicitemailandpassword, and the function throws aVALIDATION_ERRORif either is missing.seedAll()likewise fails closed when super-admin seeding is enabled but no credentials are supplied, instead of creating a known-weak default account. This removes a well-known default credential from shipped framework source.
Also hides the placeholder address the admin user menu previously showed when a user had no email (the line is now omitted when empty), and standardizes example email placeholders across the admin and form-builder UIs onto the nextly.local domain.
- Updated dependencies []:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.28
- - @nextlyhq/adapter-postgres@0.0.2-alpha.28
- - @nextlyhq/adapter-mysql@0.0.2-alpha.28
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.28
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #134 `0363799` Thanks @faisal-rx! - Remove the hardcoded default super-admin credentials from
seedSuperAdmin(). The seeder no longer falls back to a built-in email/password pair: callers (the/admin/setupwizard and the dev seed) must pass an explicitemailandpassword, and the function throws aVALIDATION_ERRORif either is missing.seedAll()likewise fails closed when super-admin seeding is enabled but no credentials are supplied, instead of creating a known-weak default account. This removes a well-known default credential from shipped framework source.
Also hides the placeholder address the admin user menu previously showed when a user had no email (the line is now omitted when empty), and standardizes example email placeholders across the admin and form-builder UIs onto the nextly.local domain.
- Updated dependencies [`0363799`]:
- - nextly@0.0.2-alpha.28
- - @nextlyhq/admin@0.0.2-alpha.28
- - @nextlyhq/ui@0.0.2-alpha.28
v0.0.2-alpha.26
AlphaReleased all 12 packages at 0.0.2-alpha.26 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
### @nextlyhq/adapter-mysql
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
- Updated dependencies [`6964718`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.26
- ### @nextlyhq/adapter-postgres
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
- Updated dependencies [`6964718`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.26
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
- Updated dependencies [`6964718`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.26
- ### @nextlyhq/admin
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
- Updated dependencies [`6964718`]:
- - @nextlyhq/ui@0.0.2-alpha.26
- ### create-nextly-app
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
### nextly
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
- Updated dependencies [`6964718`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.26
- - @nextlyhq/adapter-mysql@0.0.2-alpha.26
- - @nextlyhq/adapter-postgres@0.0.2-alpha.26
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.26
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
- Updated dependencies [`6964718`]:
- - @nextlyhq/admin@0.0.2-alpha.26
- - nextly@0.0.2-alpha.26
- - @nextlyhq/ui@0.0.2-alpha.26
- ### @nextlyhq/storage-s3
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
### @nextlyhq/storage-uploadthing
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
### @nextlyhq/ui
Patch Changes
- #123 `6964718` Thanks @aqib-rx! - Single edit forms no longer ask for a title and slug. A Single is a one-instance document whose identity is fixed by its config (
label+slug), but the admin previously rendered title and slug as editable, required inputs — forcing redundant input for values already determined by the definition.
The single edit form now shows the title (from the single's label) and slug (from the configured slug) as read-only, non-editable fields, and submitting never errors on them. EntrySystemHeader and EntryMetaStrip gain opt-in lockIdentity/lockSlug flags (default off, so collection entry forms are unchanged); for singles the title/slug are seeded from config, the client validation for those two fields is relaxed, and slug auto-generation is disabled.
v0.0.2-alpha.25
AlphaReleased all 12 packages at 0.0.2-alpha.25 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
### @nextlyhq/adapter-mysql
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
- Updated dependencies [`8cc3a1c`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.25
- ### @nextlyhq/adapter-postgres
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
- Updated dependencies [`8cc3a1c`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.25
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
- Updated dependencies [`8cc3a1c`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.25
- ### @nextlyhq/admin
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
- Updated dependencies [`8cc3a1c`]:
- - @nextlyhq/ui@0.0.2-alpha.25
- ### create-nextly-app
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
### nextly
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
- Updated dependencies [`8cc3a1c`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.25
- - @nextlyhq/adapter-mysql@0.0.2-alpha.25
- - @nextlyhq/adapter-postgres@0.0.2-alpha.25
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.25
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
- Updated dependencies [`8cc3a1c`]:
- - @nextlyhq/admin@0.0.2-alpha.25
- - nextly@0.0.2-alpha.25
- - @nextlyhq/ui@0.0.2-alpha.25
- ### @nextlyhq/storage-s3
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
### @nextlyhq/storage-uploadthing
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
### @nextlyhq/ui
Patch Changes
- #121 `8cc3a1c` Thanks @aqib-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer fail to install under pnpm 11. pnpm 11 stopped reading thepnpmfield frompackage.json, so thepnpm.onlyBuiltDependenciesallowlist the scaffolder emitted was ignored:pnpm installaborted withERR_PNPM_IGNORED_BUILDS, and past thatbetter-sqlite3never compiled its native binding (SQLite scaffolds crashed at boot) whilesharp,esbuild, andunrs-resolverwere silently blocked.
The scaffolder now writes the build-script allowlist to pnpm-workspace.yaml instead, emitting both allowBuilds (read by pnpm 11+) and onlyBuiltDependencies (read by pnpm 10.6+), and drops the now-dead pnpm field from the generated package.json. better-sqlite3 is always allow-listed so the --use-yalc dev flow — which installs every adapter — builds it too. npm, yarn, and pnpm 9 run dependency build scripts by default and ignore the file, so it is harmless under those package managers.
v0.0.2-alpha.24
AlphaReleased all 12 packages at 0.0.2-alpha.24 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
### @nextlyhq/adapter-mysql
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
- Updated dependencies [`01f3f7a`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.24
- ### @nextlyhq/adapter-postgres
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
- Updated dependencies [`01f3f7a`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.24
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
- Updated dependencies [`01f3f7a`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.24
- ### @nextlyhq/admin
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
- Updated dependencies [`01f3f7a`]:
- - @nextlyhq/ui@0.0.2-alpha.24
- ### create-nextly-app
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
### nextly
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
- Updated dependencies [`01f3f7a`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.24
- - @nextlyhq/adapter-mysql@0.0.2-alpha.24
- - @nextlyhq/adapter-postgres@0.0.2-alpha.24
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.24
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
- Updated dependencies [`01f3f7a`]:
- - @nextlyhq/admin@0.0.2-alpha.24
- - nextly@0.0.2-alpha.24
- - @nextlyhq/ui@0.0.2-alpha.24
- ### @nextlyhq/storage-s3
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
### @nextlyhq/storage-uploadthing
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
### @nextlyhq/ui
Patch Changes
- #103 `01f3f7a` Thanks @faisal-rx! - Forward
cc/bccconsistently across every email send path.
nextly.email.send and nextly.email.sendWithTemplate (Direct API) now accept and forward cc/bcc — they are added to SendEmailArgs and SendTemplateEmailArgs. Previously the Direct API namespace silently dropped both fields, so only the REST route (/api/email/send-with-template) honored them. EmailService.sendWithTemplate also dropped cc/bcc on its code-first template fallback branch while the DB-template branch already forwarded them; both branches now forward them. Empty cc/bcc arrays are not forwarded, so they don't override the "no options" path.
v0.0.2-alpha.23
AlphaReleased all 12 packages at 0.0.2-alpha.23 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
### @nextlyhq/adapter-mysql
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
- Updated dependencies [`7f7845b`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.23
- ### @nextlyhq/adapter-postgres
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
- Updated dependencies [`7f7845b`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.23
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
- Updated dependencies [`7f7845b`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.23
- ### @nextlyhq/admin
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
- Updated dependencies [`7f7845b`]:
- - @nextlyhq/ui@0.0.2-alpha.23
- ### create-nextly-app
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
### nextly
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
- Updated dependencies [`7f7845b`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.23
- - @nextlyhq/adapter-mysql@0.0.2-alpha.23
- - @nextlyhq/adapter-postgres@0.0.2-alpha.23
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.23
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
- Updated dependencies [`7f7845b`]:
- - @nextlyhq/admin@0.0.2-alpha.23
- - nextly@0.0.2-alpha.23
- - @nextlyhq/ui@0.0.2-alpha.23
- ### @nextlyhq/storage-s3
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
### @nextlyhq/storage-uploadthing
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
### @nextlyhq/ui
Patch Changes
- #101 `7f7845b` Thanks @faisal-rx! - Fix component CRUD breaking with a 500 after a dev-server config hot-reload.
reloadNextlyConfig rebuilt the runtime Drizzle descriptors for comp_* data tables with the collection/single generateRuntimeSchema, which prepends id/title/slug base columns and omits the _parent_id/_parent_table/_parent_field/_order link columns that components use to reference their parent document. This overwrote the correct boot-time registration.
After a hot-reload the bad descriptor no longer matched the physical table, so component reads (which filter by _parent_id) failed and were swallowed as "no rows", and component writes (which insert the _parent_* columns) were rejected by the database. Saving any Single or Collection document that embeds a component returned a 500.
The reload path now builds comp_* descriptors with ComponentSchemaService.generateRuntimeSchema, matching the boot path and the physical comp_* table. Adds a regression test asserting the refreshed descriptor exposes the _parent_* link columns and not title/slug.
v0.0.2-alpha.22
AlphaReleased all 12 packages at 0.0.2-alpha.22 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success. - ### @nextlyhq/adapter-mysql
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success.
- Updated dependencies [`bdece5c`, `faf14cd`, `17f0353`, `7f465db`, `7cae340`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.22
- ### @nextlyhq/adapter-postgres
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success.
- Updated dependencies [`bdece5c`, `faf14cd`, `17f0353`, `7f465db`, `7cae340`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.22
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success.
- Updated dependencies [`bdece5c`, `faf14cd`, `17f0353`, `7f465db`, `7cae340`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.22
- ### @nextlyhq/admin
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success.
- Updated dependencies [`bdece5c`, `faf14cd`, `17f0353`, `7f465db`, `7cae340`]:
- - @nextlyhq/ui@0.0.2-alpha.22
- ### create-nextly-app
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success. - ### nextly
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success.
- Updated dependencies [`bdece5c`, `faf14cd`, `17f0353`, `7f465db`, `7cae340`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.22
- - @nextlyhq/adapter-mysql@0.0.2-alpha.22
- - @nextlyhq/adapter-postgres@0.0.2-alpha.22
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.22
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success.
- Updated dependencies [`bdece5c`, `faf14cd`, `17f0353`, `7f465db`, `7cae340`]:
- - @nextlyhq/admin@0.0.2-alpha.22
- - nextly@0.0.2-alpha.22
- - @nextlyhq/ui@0.0.2-alpha.22
- ### @nextlyhq/storage-s3
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success. - ### @nextlyhq/ui
Patch Changes
- #87 `bdece5c` Thanks @faisal-rx! - Fix code-first / HMR schema applies wrongly dropping managed tables on SQLite & MySQL.
On SQLite and MySQL, drizzle-kit's pushSchema ignores tablesFilter and introspects the whole database, so any managed table missing from the desired schema was flagged as a data-losing "orphan" DROP — failing the apply and offering the table as a spurious rename source. Three cases are fixed:
- Schema-events ledger (nextly_schema_events) is now a first-class managed core table (declared in getCoreSchema / getDialectTables / CORE_TABLE_NAMES), so no schema path — apply, HMR, migrate, or db:sync — ever treats it as an orphan drop or offers it as a spurious rename target. To make it round-trip cleanly, the SQLite primary key gains an explicit NOT NULL (SQLite, unlike PG/MySQL, treats a bare TEXT PRIMARY KEY as nullable) and the SQLite partial unique index is dropped — drizzle-kit 0.31.10 cannot round-trip a SQLite partial index (drizzle-team/drizzle-orm#4688), and keeping it churned DROP/CREATE INDEX on every push. Postgres keeps its partial unique index. The "one applied row per file" guarantee is now enforced in code on all dialects: an atomic conditional markApplied (sets applied only when no other applied row exists for the filename) plus the existing cross-process migrate lock.
- UI-created collections, singles, and components are now preserved during a code-first HMR apply: every DB-registered resource is included in the desired schema (code-config entries take precedence), so adding a collection in code no longer drops resources created via the admin UI.
- Migration status: a collection added in code after the initial DB setup is now marked applied once its table is created, instead of showing pending forever in the builder listing (mirrors the existing singles behaviour).
- #87 `faf14cd` Thanks @faisal-rx! - Fix fresh-database first-run aborting on MySQL.
Now that nextly_schema_events is a core table, freshPushSchema creates it (and its indexes) during first-run setup. The setup then also replayed the out-of-band getSchemaEventsDdl unconditionally, and the MySQL raw DDL's CREATE INDEX has no IF NOT EXISTS, so it failed with a duplicate-index error and first-run reported failure on a fresh MySQL database. The out-of-band bootstrap is now guarded by a tableExists check (matching nextly migrate's ensureLedger), so it only runs as a fallback when the ledger is genuinely missing.
- #87 `17f0353` Thanks @faisal-rx! - Fix
nextly migrate:creategenerating the wrong schema for components.
The migration snapshot generator built component tables with the collection table-builder, so they came out with slug/title and were missing the component embedding columns (_parent_id, _parent_table, _parent_field, _order, _component_type). The generated snapshot then diverged from the real component table the apply pipeline creates, which made nextly migrate:resolve --applied fail its schema-match verification for any project with a component. Components now use buildDesiredTableFromComponentFields, matching the apply path.
- #87 `7f465db` Thanks @faisal-rx! - Fix
nextly migrate:createomitting the component parent index, which brokemigrate:resolve --applied.
The apply pipeline always creates a composite index (idx_<table>_parent on _parent_id, _parent_table, _parent_field) for component tables, but the migration-snapshot builder did not emit it. So the live index looked like an unmanaged extra and nextly migrate:resolve --applied failed verification ("Live schema does not match the target snapshot") for any project with a component. The snapshot builder now emits the parent index, matching the apply pipeline.
- #87 `7cae340` Thanks @faisal-rx! - Fix two
nextly_schema_eventsledger edge cases on the code-first schema path. - - Postgres index/default churn: the ledger's raw bootstrap DDL declared
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), but the Drizzle def supplies the value app-side ($defaultFn) with no SQL default. Now that the ledger is a core table flowing through drizzle-kit's Postgres diff, that mismatch made every push/migrate emitALTER COLUMN started_at DROP DEFAULT. The raw DDL now omits the redundant default (matching the MySQL/SQLite ledger DDL and theidcolumn), so the ledger round-trips cleanly with no churn. Added a Postgres round-trip integration test alongside the existing SQLite one. - -
markAppliedrace no-op: when the "one applied row per file" guard blocked a concurrent second apply, the losing row was left dangling atin_progressand the caller still logged a success.markAppliednow resolves the blocked row tosupersededand returns whether it applied, andnextly migratereports the file as already-applied-by-a-concurrent-run instead of a false success.
v0.0.2-alpha.21
AlphaReleased all 12 packages at 0.0.2-alpha.21 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list. - ### @nextlyhq/adapter-mysql
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list.
- Updated dependencies [`0e17fc6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.21
- ### @nextlyhq/adapter-postgres
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list.
- Updated dependencies [`0e17fc6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.21
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list.
- Updated dependencies [`0e17fc6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.21
- ### @nextlyhq/admin
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list.
- Updated dependencies [`0e17fc6`]:
- - @nextlyhq/ui@0.0.2-alpha.21
- ### create-nextly-app
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list. - ### nextly
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list.
- Updated dependencies [`0e17fc6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.21
- - @nextlyhq/adapter-mysql@0.0.2-alpha.21
- - @nextlyhq/adapter-postgres@0.0.2-alpha.21
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.21
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list.
- Updated dependencies [`0e17fc6`]:
- - @nextlyhq/admin@0.0.2-alpha.21
- - nextly@0.0.2-alpha.21
- - @nextlyhq/ui@0.0.2-alpha.21
- ### @nextlyhq/storage-s3
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list. - ### @nextlyhq/ui
Patch Changes
- #84 `0e17fc6` Thanks @aqib-rx! - Unified schema-migration pipeline with
ui-schema.jsondual-write. - - Migration CLI:
migrate:create/migrate/migrate:check/migrate:status, plusmigrate:downfor forward-resolved rollbacks (DOWN SQL generated at create time, renames preserved). A pooler-safe TTL migration lock replaces the session advisory lock that leaked through Neon's PgBouncer, and production deployments can run pending migrations on boot (db.runMigrationsOnBoot+db.migrateLockTtlSeconds). - -
ui-schema.jsondual-write: the admin Schema Builder always applies changes to the dev database AND writes a committableui-schema.json(the file-only mode is retired). The manifest is now a lossless record of every field option the builder/code-first can set — full validation (min/max length, pattern, etc.), per-field admin (width, description, placeholder…),unique,index, labels, the Draft/Publishedstatusflag (persisted from both the field-change and settings-only save paths), and polymorphicrelationToarrays (previously truncated to the first target). Thetogglefield type round-trips correctly. - - Correct column types:
migrate:createno longer flattens fields before diffing, so hasMany and polymorphic relationships emitjsoncolumns instead of a singletextid column. - - Diffable index/unique migrations (Postgres/MySQL/SQLite): field
unique/index, single-relationship auto-indexes, and the system slug/created_at indexes are now diffed and emitted (CREATE/DROP INDEX) with live-DB introspection, down-migration support, and a backward-compat sentinel so pre-existing tables don't churn. - - Cleanup: removed the unused
verification_tokenstable (a leftover from the retired Auth.js integration; custom auth usesemail_verification_tokensandpassword_reset_tokens).dev:resetauto-detects the dialect fromDATABASE_URL, and the ui-schema field-type set was widened to the full canonical list.
v0.0.2-alpha.20
AlphaReleased all 12 packages at 0.0.2-alpha.20 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
### @nextlyhq/adapter-mysql
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
- Updated dependencies [`f721539`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.20
- ### @nextlyhq/adapter-postgres
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
- Updated dependencies [`f721539`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.20
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
- Updated dependencies [`f721539`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.20
- ### @nextlyhq/admin
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
- Updated dependencies [`f721539`]:
- - @nextlyhq/ui@0.0.2-alpha.20
- ### create-nextly-app
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
### nextly
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
- Updated dependencies [`f721539`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.20
- - @nextlyhq/adapter-mysql@0.0.2-alpha.20
- - @nextlyhq/adapter-postgres@0.0.2-alpha.20
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.20
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
- Updated dependencies [`f721539`]:
- - @nextlyhq/admin@0.0.2-alpha.20
- - nextly@0.0.2-alpha.20
- - @nextlyhq/ui@0.0.2-alpha.20
- ### @nextlyhq/storage-s3
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
### @nextlyhq/storage-uploadthing
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
### @nextlyhq/ui
Patch Changes
- #63 `f721539` Thanks @faisal-rx! - Singles builder popup now auto-derives the slug as kebab-case to match the web convention used by public routes and the entry-form slug validator. Typing
About Pageas the singular name now fills the slug asabout-pageinstead ofabout_page. Collections and components keep their existing snake_case defaults so their backend validators continue to accept the auto-generated value unchanged. The sharedBuilderSettingsModalforwards the per-kind identifier toBasicsTab, where the slug-case helper is selected; a newtoKebabNamehelper lives alongsidetoSnakeNamein@admin/lib/builderfor downstream consumers that need URL-friendly identifiers.
create-nextly-app now resolves the published @nextlyhq/ui and @nextlyhq/plugin-form-builder versions from the npm registry alongside the other @nextlyhq/* packages it scaffolds. Generated package.json files pin both via their published semver range instead of falling back to "latest", so fresh projects install the same versions the CLI was tested against.
v0.0.2-alpha.19
AlphaReleased all 12 packages at 0.0.2-alpha.19 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
### @nextlyhq/adapter-mysql
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
- Updated dependencies [`e2b4131`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.19
- ### @nextlyhq/adapter-postgres
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
- Updated dependencies [`e2b4131`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.19
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
- Updated dependencies [`e2b4131`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.19
- ### @nextlyhq/admin
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
- Updated dependencies [`e2b4131`]:
- - @nextlyhq/ui@0.0.2-alpha.19
- ### create-nextly-app
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
### nextly
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
- Updated dependencies [`e2b4131`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.19
- - @nextlyhq/adapter-mysql@0.0.2-alpha.19
- - @nextlyhq/adapter-postgres@0.0.2-alpha.19
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.19
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
- Updated dependencies [`e2b4131`]:
- - @nextlyhq/admin@0.0.2-alpha.19
- - nextly@0.0.2-alpha.19
- - @nextlyhq/ui@0.0.2-alpha.19
- ### @nextlyhq/storage-s3
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
### @nextlyhq/storage-uploadthing
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
### @nextlyhq/ui
Patch Changes
- #61 `e2b4131` Thanks @zeshan-rx! - Admin UI polish across the Entries forms, Schema Builder, sidebar, and global loaders.
Field width is now respected end-to-end. packFieldsIntoRows no longer treats group as a block-only field, so groups participate in the same row-packing as regular fields and honour admin.width on both the builder canvas and the entry form. FieldRow adds a synthetic spacer column when a row's declared widths sum to less than 100% so partial-width fields keep their authored size instead of stretching to fill, and uses items-start so adjacent fields of different heights align cleanly. NestedFieldGroup in the schema builder uses the shared packIntoRows / parseWidth helpers to render nested children in the same row layout as the top-level canvas; repeater and group containers are forced to full width to stay readable. ComponentRow and GroupInput now delegate to FieldRow + packFieldsIntoRows instead of mapping each child through FieldRenderer directly, so nested component and group fields lay out consistently with the surrounding form. pack-fields-into-rows also guards against undefined / non-array fields input.
Entries table no longer shows the id column by default. getDefaultVisibleColumns keeps id available in the column toggler but excludes it from the initial visible set, matching the rest of the admin's "title first" presentation.
Schema Builder toolbar is now sticky. BuilderToolbar sticks to the top of the builder viewport (sticky top-0 z-30) with a solid background so it stays visible while scrolling long field lists; the collection / single / component builder pages were restructured to render the toolbar outside PageContainer so the sticky positioning has the correct scroll parent, and the container drops its bottom padding to remove the gap underneath.
Sidebar no longer flashes the empty / unauthorised state during hydration. DualSidebar now treats !isHydrated as part of hasPermissionDataPending (alongside the existing permissions-loading / error checks), so menu groups render their loading skeletons until the router and permissions are both ready instead of briefly showing nothing.
PermissionGuard loading state is replaced with a branded loader: a glassmorphic card with an ambient glow, the shared Spinner, and the Nextly brand mark animated via two new global keyframes (brand-orbit, brand-pulse) added to globals.css. A ?debug_loading=true query param force-enables the loading view to make iteration on the loader easier. Auth setup / reset-password / user-management / email-provider secret-field inputs get small consistency tweaks alongside the same loader treatment.
v0.0.2-alpha.18
AlphaReleased all 12 packages at 0.0.2-alpha.18 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
### @nextlyhq/adapter-mysql
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
- Updated dependencies [`de3ec7e`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.18
- ### @nextlyhq/adapter-postgres
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
- Updated dependencies [`de3ec7e`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.18
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
- Updated dependencies [`de3ec7e`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.18
- ### @nextlyhq/admin
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
- Updated dependencies [`de3ec7e`]:
- - @nextlyhq/ui@0.0.2-alpha.18
- ### create-nextly-app
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
### nextly
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
- Updated dependencies [`de3ec7e`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.18
- - @nextlyhq/adapter-mysql@0.0.2-alpha.18
- - @nextlyhq/adapter-postgres@0.0.2-alpha.18
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.18
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
- Updated dependencies [`de3ec7e`]:
- - @nextlyhq/admin@0.0.2-alpha.18
- - nextly@0.0.2-alpha.18
- - @nextlyhq/ui@0.0.2-alpha.18
- ### @nextlyhq/storage-s3
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
### @nextlyhq/storage-uploadthing
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
### @nextlyhq/ui
Patch Changes
- #55 `de3ec7e` Thanks @faisal-rx! - Three related singles / API consistency fixes.
REST responses for collections previously included both snake_case (created_at, updated_at) and camelCase (createdAt, updatedAt) variants of the system timestamp fields. The conversion helper added the camelCase aliases but never removed the snake_case originals, so list and detail endpoints surfaced duplicate keys per row. The snake-to-camel conversion now lives in a single helper, convertTimestampsToCamelCase, exported from shared/lib/case-conversion.ts next to the existing keysToCamelCase / keysToSnakeCase utilities. Both collection-query-service and the singles deserializeJsonFields path call it directly. The previous withTimestampAliases wrapper and its re-export from domains/collections/index.ts are removed. Collections responses now match singles / media / users / api-keys / uploads, which already emitted the camelCase form only.
The admin sidebar's singles list now renders every single in the project rather than capping at the useSingles() default page size of 10. DynamicSingleNav drives a useInfiniteQuery against the singles endpoint and walks subsequent pages while meta.hasNext is true. Each request is bounded to 100 rows so per-request DB load stays small. Secondary consumers that derive visibility or grouping data from the singles list (DualSidebar, DynamicCustomGroupNav, SinglesLandingRedirect) now pass an explicit pageSize: 100 to useSingles, matching the pattern already used by the collections sidebar fetch. This stops the same truncation symptom from hiding section headers or misrouting the /admin/singles landing redirect when the project has more than 10 singles.
The GET /admin/api/singles handler now accepts a 1-based page query parameter as an alternative to offset. The admin UI's shared buildQuery helper emits page for every paginated route; previously the singles endpoint read only offset, so a page change in the Singles builder table left the offset at 0 and the same first page was returned for every navigation. When both offset and page are supplied offset wins, preserving the existing external API contract.
v0.0.2-alpha.17
AlphaReleased all 12 packages at 0.0.2-alpha.17 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
### @nextlyhq/adapter-mysql
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
- Updated dependencies [`4d7b4f7`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.17
- ### @nextlyhq/adapter-postgres
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
- Updated dependencies [`4d7b4f7`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.17
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
- Updated dependencies [`4d7b4f7`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.17
- ### @nextlyhq/admin
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
- Updated dependencies [`4d7b4f7`]:
- - @nextlyhq/ui@0.0.2-alpha.17
- ### create-nextly-app
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
### nextly
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
- Updated dependencies [`4d7b4f7`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.17
- - @nextlyhq/adapter-mysql@0.0.2-alpha.17
- - @nextlyhq/adapter-postgres@0.0.2-alpha.17
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.17
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
- Updated dependencies [`4d7b4f7`]:
- - @nextlyhq/admin@0.0.2-alpha.17
- - nextly@0.0.2-alpha.17
- - @nextlyhq/ui@0.0.2-alpha.17
- ### @nextlyhq/storage-s3
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
### @nextlyhq/storage-uploadthing
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
### @nextlyhq/ui
Patch Changes
- #56 `4d7b4f7` Thanks @aqib-rx! - Fix the schema-apply pipeline silently skipping column type changes on Postgres, leaving the live DB permanently drifted while the journal still recorded the apply as successful.
The bug, end-to-end. When a Builder field was reclassified from a text-like type (text, richText, textarea) to a JSON-backed type (group, repeater, blocks, json, chips, point), the diff engine produced a change_column_type operation (text → jsonb on Postgres). That op type was not in the fast in-memory DDL emitter's allow-list, so the pipeline fell back to drizzle-kit's pushSchema. pushSchema considers text → jsonb a non-implicit cast and, in programmatic (non-TTY) mode, omits the ALTER COLUMN … SET DATA TYPE statement from statementsToExecute, returning the omission only in warnings. The pipeline ran the (now-empty or partial) statement list, hit no error, and the migration journal recorded status='success'. The next preview compared the live text column to the desired jsonb token from field-column-descriptor and re-detected the same drift — forever. A site running on Neon (rext-site-v2 / dc_case_studies) ended up with 10 columns stuck on text after three "successful" UI applies on 2026-05-20.
The fix. Four complementary changes in domains/schema/pipeline/:
1. The fast in-memory DDL emitter now owns change_column_type, change_column_nullable, and change_column_default on Postgres. change_column_type emits ALTER TABLE … ALTER COLUMN … SET DATA TYPE <toType> USING "<col>"::<toType> — the explicit USING cast covers the cross-family transitions that Postgres refuses to do implicitly (including the text → jsonb case), and Postgres errors loudly at execution when no registered cast exists between the source and target types. change_column_nullable emits SET NOT NULL / DROP NOT NULL per the toNullable value. change_column_default emits SET DEFAULT <expr> (raw expression, owned by build-from-fields) or DROP DEFAULT when toDefault === undefined. The three op types are added to FAST_PATH_OP_TYPES so they never reach drizzle-kit on Postgres again.
2. The code-first SQL template at sql-templates/postgres.ts (consumed by nextly migrate:create) now emits the same USING "<col>"::<toType> clause for change_column_type. Without this, code-first projects on Postgres would have produced a .sql file in the repo whose ALTER COLUMN … TYPE jsonb failed at nextly migrate apply time in CI — the same drift loop as the Builder UI path, just deferred to migration-apply time. Both consumer surfaces (the apply pipeline and the migration-file generator) now share the same USING contract.
3. Empty op lists on Postgres now also take the fast path (which emits nothing) instead of falling through to drizzle-kit. Letting drizzle-kit handle a "no ops" apply meant it ran its own catalog re-introspection and rename heuristics against the full live DB, and emitted destructive DDL that the diff engine had explicitly decided was not needed. The textarea→richText regression on rext-site-v2 / test_verify_fix surfaced this: both field types map to a text column on Postgres, so the diff produced zero column-level ops, but the slow path then attempted DROP INDEX "single_pricings_pkey" for an unrelated managed table, which Postgres rejects because a primary-key index cannot be dropped directly. Trusting our own diff for "no DDL is needed" closes that surface entirely.
4. A safety net for the slow path (MySQL / SQLite, where the in-memory emitter does not apply, or any future op type that hasn't yet been added to the fast path). After kit.pushSchema(...) returns, the pipeline now inspects pushResult.warnings; when drizzle-kit declined any statement the apply throws a PushSchemaError carrying the warning text, so the journal correctly records a failed apply rather than a false success. Operators see the precise drizzle-kit message instead of an invisible silent skip, and the next apply will not re-detect the same phantom drift.
Affected sites running on a published 0.0.2-alpha.0 … 0.0.2-alpha.16 still need a one-time ALTER TABLE … ALTER COLUMN … SET DATA TYPE jsonb USING … to relabel columns that were created as text during the silent-skip window; the fix prevents NEW drift but does not retroactively repair existing tables (running an Apply through the Builder after upgrading does the relabel automatically). Unit tests cover the three new emitter cases (including identifier-quoting through the USING clause), the routing-eligibility decisions for each (including the empty-ops case), and the safety-net throw path with a representative drizzle-kit warning payload.
v0.0.2-alpha.16
AlphaReleased all 12 packages at 0.0.2-alpha.16 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
### @nextlyhq/adapter-mysql
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
- Updated dependencies [`9bc10b6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.16
- ### @nextlyhq/adapter-postgres
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
- Updated dependencies [`9bc10b6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.16
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
- Updated dependencies [`9bc10b6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.16
- ### @nextlyhq/admin
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
- Updated dependencies [`9bc10b6`]:
- - @nextlyhq/ui@0.0.2-alpha.16
- ### create-nextly-app
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
### nextly
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
- Updated dependencies [`9bc10b6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.16
- - @nextlyhq/adapter-mysql@0.0.2-alpha.16
- - @nextlyhq/adapter-postgres@0.0.2-alpha.16
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.16
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
- Updated dependencies [`9bc10b6`]:
- - @nextlyhq/admin@0.0.2-alpha.16
- - nextly@0.0.2-alpha.16
- - @nextlyhq/ui@0.0.2-alpha.16
- ### @nextlyhq/storage-s3
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
### @nextlyhq/storage-uploadthing
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
### @nextlyhq/ui
Patch Changes
- #52 `9bc10b6` Thanks @aqib-rx! - Fix
update operation failed on table '<table>': value.toISOString is not a functionwhen saving a Single document or a component instance that includes a date field. JSON request bodies deliver date values as ISO strings (e.g."2026-05-20T12:22:29.417Z"), but Drizzle bindstimestampcolumns by calling.toISOString()on the bound value -- so an unmodified string travelling through the adapter blows up at the driver layer.CollectionMutationServicealready coerced date strings intoDateobjects inline at every write site, but the equivalent step was missing fromSingleMutationService.updateand fromComponentMutationService.serializeComponentRow(which feeds every insert / update path in the component service viabuildInsertRowand direct calls).
A new coerceDateFieldsToDate(data, fields) helper in shared/lib/field-transform.ts mutates the row in place, converting string values for field.type === "date" columns into Date objects. Existing Date, null, and undefined values pass through untouched, so the function is idempotent and safe to call on rows that were coerced upstream. The signature accepts a structural ReadonlyArray<{ name?: string; type?: string }> so the same helper covers both FieldConfig[] (singles, components) and the runtime FieldDefinition[] (collections). The helper is wired into single-mutation-service.update before snake-casing the row and into component-mutation-service.serializeComponentRow before column mapping. The six inline copies of the same coercion block in collection-mutation-service.ts were collapsed onto the shared helper as part of the same change so there is one implementation across all three domains. Result: PATCH /admin/api/singles/<slug> with a date field, inserts / updates on components with date fields, and the existing collection flows that already worked all succeed against Postgres, MySQL, and SQLite. Unit tests cover the helper's coercion, idempotency, null / undefined pass-through, and no-touch behaviour for non-date fields.
v0.0.2-alpha.15
AlphaReleased all 12 packages at 0.0.2-alpha.15 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
### @nextlyhq/adapter-mysql
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
- Updated dependencies [`ab23486`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.15
- ### @nextlyhq/adapter-postgres
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
- Updated dependencies [`ab23486`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.15
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
- Updated dependencies [`ab23486`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.15
- ### @nextlyhq/admin
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
- Updated dependencies [`ab23486`]:
- - @nextlyhq/ui@0.0.2-alpha.15
- ### create-nextly-app
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
### nextly
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
- Updated dependencies [`ab23486`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.15
- - @nextlyhq/adapter-mysql@0.0.2-alpha.15
- - @nextlyhq/adapter-postgres@0.0.2-alpha.15
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.15
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
- Updated dependencies [`ab23486`]:
- - @nextlyhq/admin@0.0.2-alpha.15
- - nextly@0.0.2-alpha.15
- - @nextlyhq/ui@0.0.2-alpha.15
- ### @nextlyhq/storage-s3
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
### @nextlyhq/storage-uploadthing
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
### @nextlyhq/ui
Patch Changes
- #51 `ab23486` Thanks @aqib-rx! - Fix users created through the admin "Create user" page being unable to sign in, and clear up the misleading checkbox that caused the silent failure in the first place.
The form's submit handler in packages/admin/src/pages/dashboard/users/create.tsx collected the "Active Account" checkbox value into values.active but never forwarded it to the API, so the backend always saw isActive as undefined and fell back to its default of false. verify-credentials.ts rejects inactive accounts at every login leg, so the newly-created user could authenticate with the right password and still see a generic "invalid credentials" error. The submit handler now sends isActive: values.active ?? true, matching the checkbox's documented "Default: Yes" UX. The backend default of false is intentionally preserved -- it is load-bearing for self-registration via /auth/register, where auth-service.verifyEmail is what flips isActive to true and gates login on proof of email ownership.
The companion checkbox was also reworked. It was labeled "Send Welcome Email" with help text "Send an email with login credentials after account creation", but it actually sets emailVerified: null and dispatches a _verification_ email -- the user could not sign in until they clicked the link. Combined with the form's "Active: Yes" default, that meant the out-of-the-box "create user" flow promised immediate login but silently delivered the opposite. The form field is now named requireEmailVerification, the label is "Require Email Verification", the help text is honest about the verification gate, the default is unchecked (so the form's "Active + immediate login" promise holds end-to-end), the checkbox is disabled when the account is inactive (verification is meaningless for a disabled account), and an inline note surfaces when both flags are on so the admin understands login is still gated until the verification link is clicked. The wire shape is unchanged -- requireEmailVerification maps onto the historical sendWelcomeEmail field at submit time so existing API consumers keep working.
v0.0.2-alpha.14
AlphaReleased all 12 packages at 0.0.2-alpha.14 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
### @nextlyhq/adapter-mysql
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
- Updated dependencies [`ea7fbe5`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.14
- ### @nextlyhq/adapter-postgres
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
- Updated dependencies [`ea7fbe5`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.14
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
- Updated dependencies [`ea7fbe5`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.14
- ### @nextlyhq/admin
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
- Updated dependencies [`ea7fbe5`]:
- - @nextlyhq/ui@0.0.2-alpha.14
- ### create-nextly-app
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
### nextly
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
- Updated dependencies [`ea7fbe5`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.14
- - @nextlyhq/adapter-mysql@0.0.2-alpha.14
- - @nextlyhq/adapter-postgres@0.0.2-alpha.14
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.14
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
- Updated dependencies [`ea7fbe5`]:
- - @nextlyhq/admin@0.0.2-alpha.14
- - nextly@0.0.2-alpha.14
- - @nextlyhq/ui@0.0.2-alpha.14
- ### @nextlyhq/storage-s3
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
### @nextlyhq/storage-uploadthing
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
### @nextlyhq/ui
Patch Changes
- #49 `ea7fbe5` Thanks @aqib-rx! - Fix two related admin-auth failures that surface on hosted databases (Neon, Supabase, PlanetScale, etc.) during transient DB hiccups.
Login/setup fluctuation. The getUserCount dependency in the auth handler bridge used to swallow any DB error and return 0, which made GET /auth/setup-status reply { isSetup: false } whenever a pool cold-start, brief disconnect, or failover landed on this endpoint — the admin route guards then redirected the user to /admin/setup, the next call returned { isSetup: true } once the DB recovered, and the guards redirected back to /admin/login, oscillating until the next hiccup or full page reload. The user count is the bootstrap-gate for two security-relevant decisions (setup-status reporting and the first-admin pre-check), and treating an unknown count as zero also opened a window where a transient DB failure during POST /auth/setup could allow a second super-admin to be created while the real first user was briefly invisible to the query. getUserCount now propagates errors; handleSetupStatus and handleSetup catch them, emit a canonical 503 SERVICE_UNAVAILABLE envelope through the shared buildAuthErrorResponse helper (application/problem+json + x-request-id), and log a structured operator event (setup-status-failed / setup-precheck-failed). The admin's PrivateRoute and PublicRoute now consume a shared lib/auth/setup-status.ts module that fail-safes to "setup complete" on any failure (network error, 5xx, invalid response shape) — staying on the dashboard or login screen is recoverable on the next request, whereas dragging an authenticated user into the setup wizard is destructive. useCurrentUserPermissions is gated by routeType === "private" so its refetchOnWindowFocus cannot fire /me/permissions during a brief Suspense window on a public route.
Intermittent logout around the access-token TTL boundary. The same swallow-and-return-null pattern lived in findUserById, which the refresh handler called after deleting the old refresh token. A momentary DB hiccup at the 15-minute boundary returned null from the lookup, the handler interpreted that as "user is gone" and ran clearAndDeny — clearing both auth cookies and revoking the still-valid session. findUserById now propagates errors; handleRefresh was reordered so all read-only lookups (findUserById, fetchRoleIds, fetchCustomFields) run BEFORE the destructive deleteRefreshToken, and is wrapped in a try/catch that returns 503 SERVICE_UNAVAILABLE on any DB failure with cookies and tokens intact — the client retries on the next request and the session survives. The admin's refreshAccessToken was a boolean primitive that treated every non-200 response (5xx, network errors, our new 503) as "session invalid" and redirected to login; it now returns a tri-state (ok / auth_failed / transient) so authFetch only redirects on a genuine 401 from /auth/refresh and surfaces transient server errors to the caller without logging the user out.
Internal: consolidated four identical build{Login,Register,Forgot,Setup}ErrorResponse helpers into a single buildAuthErrorResponse in handler-utils.ts, fixed a long-standing change-password test mock missing auditLog/trustProxy/trustedProxyIps, and added regression tests covering the 503 path on both setup endpoints, the refresh-handler 503 path (asserting no cookie clearing and no token deletion), and the "no super-admin is created when the pre-check throws" security invariant.
v0.0.2-alpha.13
AlphaReleased all 12 packages at 0.0.2-alpha.13 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
### @nextlyhq/adapter-mysql
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
- Updated dependencies [`f943cb3`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.13
- ### @nextlyhq/adapter-postgres
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
- Updated dependencies [`f943cb3`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.13
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
- Updated dependencies [`f943cb3`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.13
- ### @nextlyhq/admin
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
- Updated dependencies [`f943cb3`]:
- - @nextlyhq/ui@0.0.2-alpha.13
- ### create-nextly-app
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
### nextly
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
- Updated dependencies [`f943cb3`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.13
- - @nextlyhq/adapter-mysql@0.0.2-alpha.13
- - @nextlyhq/adapter-postgres@0.0.2-alpha.13
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.13
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
- Updated dependencies [`f943cb3`]:
- - @nextlyhq/admin@0.0.2-alpha.13
- - nextly@0.0.2-alpha.13
- - @nextlyhq/ui@0.0.2-alpha.13
- ### @nextlyhq/storage-s3
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
### @nextlyhq/storage-uploadthing
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
### @nextlyhq/ui
Patch Changes
- #46 `f943cb3` Thanks @aqib-rx! - Unified upload validation across both upload paths.
/api/medianow applies the same filename hygiene, extension blocklist, MIME allowlist, magic-byte sniff, and SVG sanitization that/admin/api/collections/[slug]/uploadsalready had — previously the global Media endpoint accepted any MIME type and any byte content up to 10MB with no sanitization. Validation logic is extracted intoservices/upload-validation/, bothUploadServiceandMediaServicecall itsvalidateAndSanitizeUploadentrypoint, and every validation failure now throwsNextlyError.validationwith a stable machine code (FILENAME_INVALID,EXTENSION_BLOCKED,MIME_BLOCKED,MIME_NOT_ALLOWED,SIZE_EXCEEDED,MAGIC_BYTE_MISMATCH,SVG_SANITIZATION_FAILED,UNSUPPORTED_FOR_BACKEND). The SVG sanitizer is tightened fromUSE_PROFILES: { svg, svgFilters }alone to explicitFORBID_TAGS(foreignObject,animate*,image,iframe,object,embed,audio,video,source,track,style) plusFORBID_ATTR(event handlers,formaction,xlink:show/actuate) and anuponSanitizeAttributehook that strips anyhref/xlink:hrefwhose value isn't fragment-only (#id). DOCTYPE declarations are stripped before sanitization to defang XML billion-laughs entity expansion, and a 2MB SVG-specific size cap is enforced separately from the general per-file limit. The magic-byte check closes a real polyglot bypass: claimingimage/svg+xmlwith non-SVG bytes (or claiming a non-SVG type with XML bytes) is now rejected before the sanitizer runs.
Breaking: UploadService.upload() now throws NextlyError.validation on validation failures instead of returning { success: false, errors, … } — storage-layer 5xx failures still return the result-shape. /api/media rejects files outside the default MIME allowlist (override via security.uploads.allowedMimeTypes or additionalMimeTypes). SVG uploads with <foreignObject>, external href, animations, <style> blocks, or data: URIs will have those elements stripped — sanitized output may differ from input. @nextlyhq/storage-vercel-blob now supports SVG uploads (previously refused). The adapter returns Vercel Blob's downloadUrl (the file URL with ?download=1 appended) when the upload requests contentDisposition: "attachment", so direct top-level navigation forces an attachment download while <img src> rendering remains unaffected. HTML uploads continue to be rejected with NextlyError.validation (code UNSUPPORTED_FOR_BACKEND, HTTP 415) — they're unsafe to host on a shared blob CDN regardless of disposition. storage-local cannot set per-file headers via Next.js static serving; sanitization still runs so stored bytes are safe, but self-hosters who want strict response headers should serve through a CDN with a response-header policy.
A new structured event nextly.upload.rejected is emitted on every validation failure with { code, route, mimeType, filename, size } so operators can alert on attack-pattern spikes (sudden bursts of MAGIC_BYTE_MISMATCH or EXTENSION_BLOCKED indicate polyglot probing).
Build/dependency: the pnpm.overrides block now bumps undici to ^7 to fix a pre-existing latent runtime bug — jsdom@28 (a transitive dep of isomorphic-dompurify) requires undici@7+'s lib/handler/wrap-handler.js, but the workspace was resolving undici@6.25.0. Any SVG upload through the existing pipeline would have crashed in production; no test exercised that path so it was undetected.
v0.0.2-alpha.12
AlphaReleased all 12 packages at 0.0.2-alpha.12 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers. - ### @nextlyhq/adapter-mysql
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers.
- Updated dependencies [`bbecc0d`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.12
- ### @nextlyhq/adapter-postgres
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers.
- Updated dependencies [`bbecc0d`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.12
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers.
- Updated dependencies [`bbecc0d`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.12
- ### @nextlyhq/admin
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers.
- Updated dependencies [`bbecc0d`]:
- - @nextlyhq/ui@0.0.2-alpha.12
- ### create-nextly-app
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers. - ### nextly
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers.
- Updated dependencies [`bbecc0d`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.12
- - @nextlyhq/adapter-mysql@0.0.2-alpha.12
- - @nextlyhq/adapter-postgres@0.0.2-alpha.12
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.12
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers.
- Updated dependencies [`bbecc0d`]:
- - @nextlyhq/admin@0.0.2-alpha.12
- - nextly@0.0.2-alpha.12
- - @nextlyhq/ui@0.0.2-alpha.12
- ### @nextlyhq/storage-s3
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers. - ### @nextlyhq/ui
Patch Changes
- #43 `bbecc0d` Thanks @faisal-rx! - Fresh projects scaffolded with
pnpm create nextly-appno longer crash at boot under pnpm 10+. pnpm 10 blocks dependency install scripts by default, and without an allowlistbetter-sqlite3never built its native binding, so SQLite scaffolds threwCould not locate the bindings fileon the first admin request.sharp,esbuild, andunrs-resolverwere silently blocked too, producing a slow JS image fallback, drizzle-kit slowness, and an eslint resolver warning respectively. The scaffolder now emitspnpm.onlyBuiltDependenciesin the generatedpackage.json:sharp,esbuild, andunrs-resolveralways, plusbetter-sqlite3when the SQLite adapter is selected. npm, yarn, and bun ignore thepnpm-namespaced field, so it is harmless under those package managers.
v0.0.2-alpha.11
AlphaReleased all 12 packages at 0.0.2-alpha.11 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect. - ### @nextlyhq/adapter-mysql
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect.
- Updated dependencies [`50151bc`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.11
- ### @nextlyhq/adapter-postgres
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect.
- Updated dependencies [`50151bc`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.11
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect.
- Updated dependencies [`50151bc`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.11
- ### @nextlyhq/admin
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect.
- Updated dependencies [`50151bc`]:
- - @nextlyhq/ui@0.0.2-alpha.11
- ### create-nextly-app
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect. - ### nextly
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect.
- Updated dependencies [`50151bc`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.11
- - @nextlyhq/adapter-mysql@0.0.2-alpha.11
- - @nextlyhq/adapter-postgres@0.0.2-alpha.11
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.11
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect.
- Updated dependencies [`50151bc`]:
- - @nextlyhq/admin@0.0.2-alpha.11
- - nextly@0.0.2-alpha.11
- - @nextlyhq/ui@0.0.2-alpha.11
- ### @nextlyhq/storage-s3
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect. - ### @nextlyhq/ui
Patch Changes
- #41 `50151bc` Thanks @aqib-rx! - Fix drizzle-kit rename TUI ("Is
dc_poststable created or renamed from another table?") firing on SQLite and MySQL after the schema-apply scope-reduction landed. The scope-reduction filter iterated by managed-table names and stripped the static system tables thatbuildDrizzleSchemainjects so drizzle-kit's diff recognises them. On SQLite/MySQL drizzle-kit ignorestablesFilter, so the missing system tables looked like drops, paired with the managed adds, and produced the rename TUI on every fresh-install boot — crashing Next.js's non-TTY server thread. The scope-reduction filter now preserves non-managed entries via!isManagedTable(name), restoring the injection's intended effect on every dialect.
v0.0.2-alpha.9
AlphaReleased all 12 packages at 0.0.2-alpha.9 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
### @nextlyhq/adapter-mysql
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
- Updated dependencies [`10479d0`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.9
- ### @nextlyhq/adapter-postgres
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
- Updated dependencies [`10479d0`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.9
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
- Updated dependencies [`10479d0`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.9
- ### @nextlyhq/admin
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
- Updated dependencies [`10479d0`]:
- - @nextlyhq/ui@0.0.2-alpha.9
- ### create-nextly-app
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
### nextly
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
- Updated dependencies [`10479d0`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.9
- - @nextlyhq/adapter-mysql@0.0.2-alpha.9
- - @nextlyhq/adapter-postgres@0.0.2-alpha.9
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.9
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
- Updated dependencies [`10479d0`]:
- - @nextlyhq/admin@0.0.2-alpha.9
- - nextly@0.0.2-alpha.9
- - @nextlyhq/ui@0.0.2-alpha.9
- ### @nextlyhq/storage-s3
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
### @nextlyhq/storage-uploadthing
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
### @nextlyhq/ui
Patch Changes
- #36 `10479d0` Thanks @faisal-rx! - Media URLs returned from the API are now absolute. Previously, the local storage adapter wrote
/uploads/...paths and surfaced them verbatim in API responses — mobile clients, edge workers, and any consumer without the deployment's origin baked in could not resolve the URL. Now,MediaServiceresponses, populatedmediarelations on entry responses, and the collection upload handlers (POST/GET /admin/api/collections/<slug>/uploads) prefix relative URLs withNEXT_PUBLIC_APP_URL(priority:emailConfig.baseUrloverride >NEXT_PUBLIC_APP_URL>http://localhost:3000in dev). Cloud-adapter URLs (S3, Vercel Blob, R2) are already absolute and pass through unchanged. Consumers that previously concatenated the base URL themselves should drop the prefix — double-prefix detection is in place, but the new behaviour means the prefix is no longer needed. The env schema already requiresNEXT_PUBLIC_APP_URLin production, so the localhost fallback is only reachable in development.
Internal: extracted a shared getBaseUrl(override?) helper at src/shared/lib/get-base-url.ts so the email service and the new media-absolutization path resolve through one priority chain. EmailService.getBaseUrl and the new getMediaBaseUrl both delegate to it.
v0.0.2-alpha.8
AlphaReleased all 12 packages at 0.0.2-alpha.8 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution. - ### @nextlyhq/adapter-mysql
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution.
- Updated dependencies [`a5d2af6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.8
- ### @nextlyhq/adapter-postgres
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution.
- Updated dependencies [`a5d2af6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.8
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution.
- Updated dependencies [`a5d2af6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.8
- ### @nextlyhq/admin
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution.
- Updated dependencies [`a5d2af6`]:
- - @nextlyhq/ui@0.0.2-alpha.8
- ### create-nextly-app
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution. - ### nextly
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution.
- Updated dependencies [`a5d2af6`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.8
- - @nextlyhq/adapter-mysql@0.0.2-alpha.8
- - @nextlyhq/adapter-postgres@0.0.2-alpha.8
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.8
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution.
- Updated dependencies [`a5d2af6`]:
- - @nextlyhq/admin@0.0.2-alpha.8
- - nextly@0.0.2-alpha.8
- - @nextlyhq/ui@0.0.2-alpha.8
- ### @nextlyhq/storage-s3
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution. - ### @nextlyhq/ui
Patch Changes
- #34 `a5d2af6` Thanks @aqib-rx! - Fix severe Builder slowness and connection-pool exhaustion when running Nextly against Neon Postgres, and complete the code-first column-delete workflow. Adapter now wires the provider's declared
statementTimeoutMsintopg.Pool(Neon's 30s default was previously ignored, letting stuck queries pin pool slots forever) and bumps Node 20+'s 250 ms Happy Eyeballs per-address timeout floor to 5 s on first connect so transcontinental Neon endpoints stop surfacingETIMEDOUTafter exhausting every resolved address.DB_POOL_MAX/MIN/IDLE_TIMEOUT/QUERY_TIMEOUTenv vars were always documented but never plumbed into the factory — they now flow through with per-field??fallback so each value can fall back to the adapter's dialect-specific defaults (notably the PG adapter'smin: 0for Neon auto-suspend recovery). Boot/HMR drift-check now uses bounded concurrency (3 workers) instead of unboundedPromise.allthat saturated a Neon pool of 5 with 10+ collections. HMRserverComponentChangesevents get a 300 ms trailing debounce so editor burst-saves stop firing a full pipeline per save. A short-lived live-snapshot cache deduplicates the twointrospectLiveSnapshotcalls that previously fired during a single Builder apply, and a missinginstrumentation.tswarning surfaces in dev to nudge users toward the single-worker warmup pattern. A new fast in-memory DDL emitter on PostgreSQL bypasses drizzle-kit's ~10 s catalog re-introspection for the common Builder op set (add_column,add_table), and even on the slow-path fallback the pushSchema call is now scoped to only the table(s) actually touched by the resolved ops rather than every managed table.filterUnsafeStatementsalso blocks orphanDROP SEQUENCE/DROP INDEXwhose inferred owner table is not in the desired schema. A new diff-time default normaliser collapses Postgres's redundant::<type>cast suffix (e.g.'draft'::character varying) and lowercasesnow()so the diff stops emitting phantomchange_column_defaultops for every system column on every apply; a long-standing descriptor drift betweenruntime-schema-generatorandfield-column-descriptor(statustextvsvarchar, missingnow()defaults oncreated_at/updated_at) is also fixed so the new fast path actually triggers in the real Builder flow. End-to-end on a real Neon instance: Builder Save HTTP timing drops from ~11 s to ~5 s and the in-pipeline schema apply drops from ~10 s to ~1.4 s. Code-first column deletes now flow through a newdestructive_dropClassifierEventthat theClackTerminalPromptDispatcherrenders as aDrop "<column>" from "<table>"?confirm in the dev terminal — removing a field fromnextly.config.tsand saving prompts you to confirm before destroying data, matching Drizzle Kit'spushUX;NEXTLY_ALLOW_CODE_FIRST_DROPS=1auto-confirms every drop without prompting for CI/non-interactive workflows. Finally, the API Playground response viewer no longer crashes with "Unrecognized extension value" — the admin bundle was loading two copies of@codemirror/state(6.5.3 + 6.6.0) which brokeinstanceof Extension; apnpm.overridespin forces a single resolution.
v0.0.2-alpha.10
AlphaReleased all 12 packages at 0.0.2-alpha.10 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
### @nextlyhq/adapter-mysql
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
- Updated dependencies [`04da3a7`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.10
- ### @nextlyhq/adapter-postgres
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
- Updated dependencies [`04da3a7`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.10
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
- Updated dependencies [`04da3a7`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.10
- ### @nextlyhq/admin
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
- Updated dependencies [`04da3a7`]:
- - @nextlyhq/ui@0.0.2-alpha.10
- ### create-nextly-app
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
### nextly
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
- Updated dependencies [`04da3a7`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.10
- - @nextlyhq/adapter-mysql@0.0.2-alpha.10
- - @nextlyhq/adapter-postgres@0.0.2-alpha.10
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.10
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
- Updated dependencies [`04da3a7`]:
- - @nextlyhq/admin@0.0.2-alpha.10
- - nextly@0.0.2-alpha.10
- - @nextlyhq/ui@0.0.2-alpha.10
- ### @nextlyhq/storage-s3
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
### @nextlyhq/storage-uploadthing
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
### @nextlyhq/storage-vercel-blob
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
### @nextlyhq/ui
Patch Changes
- #38 `04da3a7` Thanks @faisal-rx! - Fix: variant URLs in populated
media.sizes[*].urlare now absolutized too. The initial absolutization pass only rewrote the top-levelurlandthumbnailUrlfields, so on SQLite — which storesmedia.sizesas TEXT and returns the column as an unparsed JSON string — clients consuminggetMediaVariant(media, "card")on populated entries still received relative/uploads/...paths.absolutizeMediaUrlsnow normalises string-encoded sizes into an object before rewriting variant URLs, so populated media on entry responses returns reachable variant URLs across every dialect. Unparseable JSON resolves tonullrather than leaking the raw string to the API consumer.
Also: toAbsoluteMediaUrl and absolutizeMediaUrls resolve baseUrl lazily — the env-backed default fires only when a relative URL actually needs prefixing. Pass-through cases (absolute URLs, null/undefined/empty) no longer touch the env proxy, so the "absolute URLs unchanged" contract holds in contexts that have not booted env validation (isolated tests, bundler-time analysis).
v0.0.2-alpha.7
AlphaReleased all 12 packages at 0.0.2-alpha.7 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
### @nextlyhq/adapter-mysql
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
- Updated dependencies [`e41725d`, `bd92f1b`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.7
- ### @nextlyhq/adapter-postgres
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
- Updated dependencies [`e41725d`, `bd92f1b`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.7
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
- Updated dependencies [`e41725d`, `bd92f1b`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.7
- ### @nextlyhq/admin
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
- Updated dependencies [`e41725d`, `bd92f1b`]:
- - @nextlyhq/ui@0.0.2-alpha.7
- ### create-nextly-app
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
### nextly
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
- Updated dependencies [`e41725d`, `bd92f1b`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.7
- - @nextlyhq/adapter-mysql@0.0.2-alpha.7
- - @nextlyhq/adapter-postgres@0.0.2-alpha.7
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.7
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
- Updated dependencies [`e41725d`, `bd92f1b`]:
- - @nextlyhq/admin@0.0.2-alpha.7
- - nextly@0.0.2-alpha.7
- - @nextlyhq/ui@0.0.2-alpha.7
- ### @nextlyhq/storage-s3
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
### @nextlyhq/storage-uploadthing
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
### @nextlyhq/storage-vercel-blob
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
### @nextlyhq/ui
Patch Changes
- #32 `e41725d` Thanks @mobeenabdullah! - Internal refactor: consolidate the
packages/nextly/src/services/auth/shim layer. The shim was a directory of one-lineexport *re-exports left over from an earlier reorganisation; the canonical code already lived inpackages/nextly/src/domains/auth/services/. The shim directory has been removed and 29 internal call sites have been pointed at the canonical location. A duplicate test suite of 13 files (mechanical-path-only drift, no logic divergence) has been deleted in favour of the existing copies underdomains/auth/__tests__/. A new@nextly/domains/*TypeScript path alias is added to match the existing@nextly/services/*/@nextly/auth/*pattern. No public exports, runtime behaviour, or wire-format changes; this is shipped as a patch because every package version moves together in the alpha train.
- #30 `bd92f1b` Thanks @mobeenabdullah! -
create-nextly-appnow prompts for a folder name when none is given on the command line. Previously, runningnpx create-nextly-appwith no positional argument was silently treated as "install in the current directory" and then aborted with aDirectory not emptyerror once the user finished the template and database prompts. The CLI now asksWhat should your project be called?withmy-nextly-apppre-filled. You can accept the default with Enter, type any folder name, or type.(or./) to install in the current directory, matching the way the positional argument already worked. When the chosen target directory is non-empty the CLI now offers a three-option recovery prompt (cancel, remove existing files and continue, or ignore files and continue) instead of aborting outright. Theremoveoption preserves any.gitdirectory so existing history is kept.
Note for scripted or CI use: the no-argument form is no longer equivalent to npx create-nextly-app .; it now opens an interactive prompt. If you were relying on the previous behavior in a non-interactive environment, pass . (or any folder name) explicitly.
v0.0.2-alpha.6
AlphaReleased all 12 packages at 0.0.2-alpha.6 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it. - ### @nextlyhq/adapter-mysql
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it.
- Updated dependencies [`338b668`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.6
- ### @nextlyhq/adapter-postgres
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it.
- Updated dependencies [`338b668`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.6
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it.
- Updated dependencies [`338b668`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.6
- ### @nextlyhq/admin
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it.
- Updated dependencies [`338b668`]:
- - @nextlyhq/ui@0.0.2-alpha.6
- ### create-nextly-app
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it. - ### nextly
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it.
- Updated dependencies [`338b668`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.6
- - @nextlyhq/adapter-mysql@0.0.2-alpha.6
- - @nextlyhq/adapter-postgres@0.0.2-alpha.6
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.6
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it.
- Updated dependencies [`338b668`]:
- - @nextlyhq/admin@0.0.2-alpha.6
- - nextly@0.0.2-alpha.6
- - @nextlyhq/ui@0.0.2-alpha.6
- ### @nextlyhq/storage-s3
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it. - ### @nextlyhq/ui
Patch Changes
- #28 `338b668` Thanks @faisal-rx! - Fix
Cannot find package '@nextlyhq/plugin-form-builder'onpnpm devfor blank scaffolds. The base admin page (templates/base/src/app/admin/[[...params]]/page.tsx) and the existing-project admin generator both hard-coded three side-effect imports for@nextlyhq/plugin-form-builder, but the package was only added topackage.jsonon the fresh-scaffold npm path. Blank scaffolds and existing-project installs got the imports without the dep, sonext devfailed at module resolution. The plugin is now opt-in per template: blank ships a plugin-less admin page; the blog template overlays a blog-specific admin page that re-adds the imports (mirroring howformBuilderPluginis registered only in the blog config).generatePackageJsonand the yalc paths ininstallDependenciesaccept aprojectTypeand only include@nextlyhq/plugin-form-builderwhen the selected template uses it.
v0.0.2-alpha.5
AlphaReleased all 12 packages at 0.0.2-alpha.5 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres. - ### @nextlyhq/adapter-mysql
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres.
- Updated dependencies [`fc88dc2`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.5
- ### @nextlyhq/adapter-postgres
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres.
- Updated dependencies [`fc88dc2`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.5
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres.
- Updated dependencies [`fc88dc2`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.5
- ### @nextlyhq/admin
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres.
- Updated dependencies [`fc88dc2`]:
- - @nextlyhq/ui@0.0.2-alpha.5
- ### create-nextly-app
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres. - ### nextly
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres.
- Updated dependencies [`fc88dc2`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.5
- - @nextlyhq/adapter-mysql@0.0.2-alpha.5
- - @nextlyhq/adapter-postgres@0.0.2-alpha.5
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.5
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres.
- Updated dependencies [`fc88dc2`]:
- - @nextlyhq/admin@0.0.2-alpha.5
- - nextly@0.0.2-alpha.5
- - @nextlyhq/ui@0.0.2-alpha.5
- ### @nextlyhq/storage-s3
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres. - ### @nextlyhq/ui
Patch Changes
- #26 `fc88dc2` Thanks @mobeenabdullah! - Collection mutation paths now resolve the physical table through
collection.tableName, honoringdbNameoverrides instead of always deriving the name from the slug. The code-first boot sync detects when a collection's resolvedtableNamediffers from the row indynamic_collections, renames the physical table (Postgres/SQLite/MySQL quotedALTER TABLE ... RENAME TO), writes the new name back, and invalidates the cached Drizzle schema inCollectionFileManagerso the next request rebuilds against the renamed table — previously adbNamechange left CRUD pointing at the stale table until a server restart. When both the old and new physical tables exist, the rename is skipped with a warn so the user can resolve the conflict manually. Component runtime-schema refresh after a UI-driven create/update/apply now flows through the DISchemaRegistry(with a typed fallback to the adapter'stableResolverfor non-DI paths) and surfaces failures as warnings instead of swallowing them in a silent try/catch — the prior behavior leftcomp_*queries selecting pre-rename column names until restart. Generated timestamp columns (createdAt,updatedAt) now emitwithTimezone: false/ plainTIMESTAMPfor Postgres, aligning behavior across SQLite, MySQL, and Postgres.
v0.0.2-alpha.4
AlphaReleased all 12 packages at 0.0.2-alpha.4 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either. - ### @nextlyhq/adapter-mysql
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either.
- Updated dependencies [`af98b55`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.4
- ### @nextlyhq/adapter-postgres
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either.
- Updated dependencies [`af98b55`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.4
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either.
- Updated dependencies [`af98b55`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.4
- ### @nextlyhq/admin
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either.
- Updated dependencies [`af98b55`]:
- - @nextlyhq/ui@0.0.2-alpha.4
- ### create-nextly-app
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either. - ### nextly
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either.
- Updated dependencies [`af98b55`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.4
- - @nextlyhq/adapter-mysql@0.0.2-alpha.4
- - @nextlyhq/adapter-postgres@0.0.2-alpha.4
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.4
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either.
- Updated dependencies [`af98b55`]:
- - @nextlyhq/admin@0.0.2-alpha.4
- - nextly@0.0.2-alpha.4
- - @nextlyhq/ui@0.0.2-alpha.4
- ### @nextlyhq/storage-s3
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either. - ### @nextlyhq/ui
Patch Changes
- #23 `af98b55` Thanks @mobeenabdullah! - Fix Single document fields appearing empty after a component-field rename. Schema-apply and external-schema-update handlers invalidated
["collections"],["entries"],["singles"], and["components"]— but Single document data lives under a separate["single-documents"]namespace (used byuseSingleDocument), which was never invalidated. After a rename,useSingleSchemarefetched with the new field name whileuseSingleDocumentkept serving cached data keyed by the old name, so the form rendereddata[newName]asundefinedand the field appeared blank until a hard refresh. Collections were unaffected becauseuseEntrylives under["entries"], which was already in the invalidation list. The["single-documents"]key is now invalidated alongside the others. Also propagate the Draft/Publishedstatusflag throughbuildFullDesiredSchemafor both collections and singles, mirroring the earlier preview-pipeline fix so the full-schema build path doesn't drop the column either.
v0.0.2-alpha.3
AlphaReleased all 12 packages at 0.0.2-alpha.3 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission. - ### @nextlyhq/adapter-mysql
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission.
- Updated dependencies [`7f4d5d4`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.3
- ### @nextlyhq/adapter-postgres
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission.
- Updated dependencies [`7f4d5d4`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.3
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission.
- Updated dependencies [`7f4d5d4`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.3
- ### @nextlyhq/admin
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission.
- Updated dependencies [`7f4d5d4`]:
- - @nextlyhq/ui@0.0.2-alpha.3
- ### create-nextly-app
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission. - ### nextly
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission.
- Updated dependencies [`7f4d5d4`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.3
- - @nextlyhq/adapter-mysql@0.0.2-alpha.3
- - @nextlyhq/adapter-postgres@0.0.2-alpha.3
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.3
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission.
- Updated dependencies [`7f4d5d4`]:
- - @nextlyhq/admin@0.0.2-alpha.3
- - nextly@0.0.2-alpha.3
- - @nextlyhq/ui@0.0.2-alpha.3
- ### @nextlyhq/storage-s3
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission. - ### @nextlyhq/ui
Patch Changes
- #19 `7f4d5d4` Thanks @aqib-rx! - HTTP read endpoints now return entries/documents regardless of status by default. Previously,
GET /api/collections/<slug>/entries,GET /api/collections/<slug>/entries/<id>,GET /api/collections/<slug>/entries/count, andGET /api/singles/<slug>defaulted to "published-only" and required?status=allto see drafts — confusing for the admin API Playground, which returned 404 for any status-enabled single or collection whose only document was still in draft. The new default is to return all records; pass?status=published(or?status=draft) to filter explicitly. The routes still require authentication, so this only affects callers that already have read permission.
v0.0.2-alpha.2
AlphaReleased all 12 packages at 0.0.2-alpha.2 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits. - ### @nextlyhq/adapter-mysql
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits.
- Updated dependencies [`8e77998`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.2
- ### @nextlyhq/adapter-postgres
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits.
- Updated dependencies [`8e77998`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.2
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits.
- Updated dependencies [`8e77998`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.2
- ### @nextlyhq/admin
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits.
- Updated dependencies [`8e77998`]:
- - @nextlyhq/ui@0.0.2-alpha.2
- ### create-nextly-app
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits. - ### nextly
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits.
- Updated dependencies [`8e77998`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.2
- - @nextlyhq/adapter-mysql@0.0.2-alpha.2
- - @nextlyhq/adapter-postgres@0.0.2-alpha.2
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.2
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits.
- Updated dependencies [`8e77998`]:
- - @nextlyhq/admin@0.0.2-alpha.2
- - nextly@0.0.2-alpha.2
- - @nextlyhq/ui@0.0.2-alpha.2
- ### @nextlyhq/storage-s3
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits. - ### @nextlyhq/storage-uploadthing
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits. - ### @nextlyhq/storage-vercel-blob
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits. - ### @nextlyhq/ui
Patch Changes
- #17 `8e77998` Thanks @aqib-rx! - Fix UI Schema Builder silently dropping the Draft/Published
statuscolumn when editing a collection or single. Saving a field change on astatus: trueentity used to surface a "Rename status → \<new field\>" option (selected by default) becausepreviewDesiredSchemadid not propagate the Draft/Published flag into the desired snapshot — confirming the dialog DROPped the column and every subsequent entry POST withstatus: "published"failed withtable dc_<slug> has no column named status. The flag now flows through the preview/apply pipeline for both collections and singles, so the column survives edits.
v0.0.2-alpha.1
AlphaReleased all 12 packages at 0.0.2-alpha.1 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- ### @nextlyhq/adapter-mysql
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- Updated dependencies [`098d5b1`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.1
- ### @nextlyhq/adapter-postgres
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- Updated dependencies [`098d5b1`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.1
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- Updated dependencies [`098d5b1`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.1
- ### @nextlyhq/admin
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- Updated dependencies [`098d5b1`]:
- - @nextlyhq/ui@0.0.2-alpha.1
- ### create-nextly-app
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- ### nextly
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- Updated dependencies [`098d5b1`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.1
- - @nextlyhq/adapter-mysql@0.0.2-alpha.1
- - @nextlyhq/adapter-postgres@0.0.2-alpha.1
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.1
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- Updated dependencies [`098d5b1`]:
- - @nextlyhq/admin@0.0.2-alpha.1
- - nextly@0.0.2-alpha.1
- - @nextlyhq/ui@0.0.2-alpha.1
- ### @nextlyhq/storage-s3
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- ### @nextlyhq/storage-uploadthing
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- ### @nextlyhq/storage-vercel-blob
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
- ### @nextlyhq/ui
Patch Changes
- #13 `098d5b1` Thanks @mobeenabdullah! - Iterative alpha bump: clean stale @nextly/ in adapter descriptions; contributor bootstrap fix; first OIDC-published release.
v0.0.2-alpha.0
AlphaReleased all 12 packages at 0.0.2-alpha.0 in lockstep (nextly, create-nextly-app, and 10 @nextlyhq/* packages).
What's changed
@nextlyhq/adapter-drizzle
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app ```
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app
- Updated dependencies [`de96251`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.0
- ### @nextlyhq/adapter-postgres
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app
- Updated dependencies [`de96251`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.0
- ### @nextlyhq/adapter-sqlite
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app
- Updated dependencies [`de96251`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.0
- ### @nextlyhq/admin
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app
- Updated dependencies [`de96251`]:
- - @nextlyhq/ui@0.0.2-alpha.0
- ### create-nextly-app
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app ```
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app
- Updated dependencies [`de96251`]:
- - @nextlyhq/adapter-drizzle@0.0.2-alpha.0
- - @nextlyhq/adapter-postgres@0.0.2-alpha.0
- - @nextlyhq/adapter-mysql@0.0.2-alpha.0
- - @nextlyhq/adapter-sqlite@0.0.2-alpha.0
- ### @nextlyhq/plugin-form-builder
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app
- Updated dependencies [`de96251`]:
- - nextly@0.0.2-alpha.0
- - @nextlyhq/admin@0.0.2-alpha.0
- - @nextlyhq/ui@0.0.2-alpha.0
- ### @nextlyhq/storage-s3
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app ```
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app ```
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app ```
Patch Changes
- #4 `de96251` Thanks @mobeenabdullah! - Initial alpha release of Nextly — a TypeScript-first, Next.js-native CMS and app framework.
All 12 packages publish at 0.0.2-alpha.0 in lockstep under the alpha dist-tag.
Highlights:
- Core (nextly) — REST + Direct API, RBAC, hooks, and the runtime engine. API key prefix is nx_live_.
- Admin (@nextlyhq/admin) — Full-featured admin dashboard.
- UI (@nextlyhq/ui) — Headless component primitives shared across packages and plugins.
- CLI (create-nextly-app) — Project scaffolder with blog and blank templates, multi-DB picker, telemetry opt-out.
- Database adapters — @nextlyhq/adapter-postgres, @nextlyhq/adapter-mysql, @nextlyhq/adapter-sqlite, plus the shared @nextlyhq/adapter-drizzle base.
- Storage adapters — @nextlyhq/storage-s3 (also R2 / MinIO / B2 / Wasabi), @nextlyhq/storage-vercel-blob, @nextlyhq/storage-uploadthing.
- Plugins (preview) — @nextlyhq/plugin-form-builder for early exploration; public plugin APIs stabilize at the beta release.
Alpha caveats: APIs may change before 1.0. Pin exact versions in production.
Install:
pnpm create nextly-app@alpha my-app # or npx create-nextly-app@alpha my-app
Stay up to date
Follow Nextly's development and get notified about new releases.