v0.0.2-alpha.58
Pre-releaseReleased 19 packages at 0.0.2-alpha.58 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
-
#720
8a7e734Thanks @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-strongand--nx-sidebar-bordermove to the
reference border weight;--nx-destructiveand--nx-destructive-solidmove to
the reference red;--nx-sidebar-foregroundmatches 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 newcontrast/accepted.tswith 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-borderrather 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
f53dbd8Thanks @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
useStatecalls plus their ownhandlePageSizeChangewrapper that set the size and then reset the page. They now use the existingusePaginationhook, which owns those resets. That removes the drift risk across the copies and, more usefully, removes the wrapper entirely:onPageSizeChangeis the hook'ssetPageSize, 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 nowPAGINATION.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'spageSizeOptionsis typedreadonly 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
f5a5405Thanks @mobeenabdullah! - AddGET /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
376a3a4Thanks @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:
FindUsersArgsno longer inherits
theFindArgsoptions thatusers.find()does not implement —where,
status,sort,select,populateandpagination. Passing them compiled
and did nothing, so awhereclause 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
8dc013eThanks @mobeenabdullah! - Refuse to start when boot migrations did not run. Withdb.runMigrationsOnBoot,
an instance that could not take the migrate lock before its wait deadline used
to logBoot 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.withMigrateLockreports whether its body ran instead of returningundefined
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
7ea3567Thanks @mobeenabdullah! - fix(create-nextly-app): generate a build script that runs on Windows, and stop swallowing a failed search-index build -
#752
59d84ddThanks @mobeenabdullah! - Add a command palette to the page-builder editor. Opens onmod+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
51ddce0Thanks @mobeenabdullah! - point the builder dev watchers atsrc, sopnpm devrebuilds againtsup --watchdefaults to watching., and at that root it never notices an
edit: noChange detected, no rebuild, and an artifact byte-identical
afterwards. Measured back to back on tsup 8.5.0 —--watch .saw nothing,
--watch srcdetected 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 thenWatching for changes, and the only symptom
is the ABSENCE of a later build line in output that scrolls. Anyone debugging a
staledistwas debugging code that had never been rebuilt. -
#733
24a3a4dThanks @mobeenabdullah! - add the page-builder editor shell@nextlyhq/buildergainsBuilderShell— 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 thePreferenceStore
port a host implements to keep chrome preferences wherever it already keeps
preferences.New subpath
@nextlyhq/builder/styles.csscarries the--nx-builder-*chrome
token layer. A consumer that renders the shell without importing it gets
unstyled markup. -
#682
7b19d8aThanks @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
7a23525Thanks @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
a6555f8Thanks @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
cec9cc3Thanks @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
faf7fd7Thanks @mobeenabdullah! - Addcore/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'sallowlist 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'sBlockDefinition, so it reaches plugin authors through@nextlyhq/plugin-sdk/blocksalongside 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-sideinit, and the admin's client config transports onlyremotePatterns, 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/columnusesparentso inserting a Column while one is selected produces a sibling in the row rather than a column nested inside a column.blocks.manifest.jsoncarriesparent, and itsmanifestVersionmoves 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'smigratereachable 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
migratereachable 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
29e8129Thanks @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
fb9a0c0Thanks @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
5fc9cc7Thanks @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
e344e47Thanks @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
8bb149fThanks @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 newdivergedmigration 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
7b23e26Thanks @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
4c8d39cThanks @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
a5ab500Thanks @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/yyyyformat hint and ignoresplaceholderoutright, 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
RangeFieldwith 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
9cdbbe1Thanks @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
d6f526eThanks @mobeenabdullah! - GiveDataTableViewapaginationprop 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
DataTableViewis 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
paginationas data:currentPage,pageSize,onPageChangeand 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, andMediaListViewforwards 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
7948d1fThanks @mobeenabdullah! - fix(create-nextly-app): keep pnpm add working in a pnpm scaffold -
#771
fc92a4dThanks @mobeenabdullah! - Decide a boxed BigInt by its internal slot rather than bySymbol.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
f29ebebThanks @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
9a291feThanks @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
b58f55cThanks @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
a0e2817Thanks @mobeenabdullah! - Make one control size name mean one control height.size="sm"resolved to--nx-control-height-md(36px) onButtonand--nx-control-height-sm(32px) onInputandSelectTrigger, so a small button beside a small input or select sat 4px out of line.defaultandlgalready agreed; onlysmdiverged.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
cvafunctions 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-9directly, which happened to equal the small step and then stopped tracking it. It takessize="sm"now, and its icon is centred rather than offset by a fixedtop-2.5that only centred inside a 36px control — the same height decision written a second time. -
#857
224c729Thanks @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_PATHSinconstants/routes.tsis 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
b55e278Thanks @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:
falsestill 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
332d56eThanks @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
f7545feThanks @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
e19f31aThanks @mobeenabdullah! - fix(nextly): persist the admin options a collection is allowed to setorder 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
c92db86Thanks @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 hostpluginOverridesapply 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
e24638cThanks @mobeenabdullah! - Warn at boot when a plugin ships without anadmin.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
2f2f089Thanks @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
d4f6480Thanks @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
85d526eThanks @mobeenabdullah! - Disclose the routes a disabled plugin would serve once enabled. A disabled
plugin mounts no routes, soroutesstays empty and the same declarations
travel aswhenEnabledinstead — 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
f0b9f1dThanks @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: apublishorunpublishdeclaration 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
4fdbf77Thanks @mobeenabdullah! - The entry editor now offers Copy shareable link.The preview-link machinery already shipped — a mint route gated by
update, an admin service, ausePreviewLinkhook and thePreviewActionscontrol — 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
updateon 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
1b0689eThanks @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
5244934Thanks @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
2f3bb57Thanks @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
791a08eThanks @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 aslockon 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
basisto 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
0b3fc78Thanks @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
20c1d43Thanks @mobeenabdullah! - Stop generating adb:migrate:resetscript that names a command the CLI does not
register. Every scaffolded project shipped annpm run db:migrate:resetthat
failed;db:migrate:freshalready drops all tables and re-runs the migrations. -
#759
e520db5Thanks @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
d9bbcf6Thanks @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
b09b087Thanks @mobeenabdullah! - Make the admin search field anInputrather than a second implementation of one.SearchBarrestated Input's classes instead of composing it, and the copy had drifted twelve ways: noaria-invalidordata-[invalid=true]handling at all, so a search field could not show an error state;focus:border-primarywithout the!Input uses; and noselection:*colours,placeholder:opacity-50ordisabled: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
classNamereaches the wrapper, not the field, so theborder-inputandborder-borderclasses 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.
Inputalso 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 toinheriton form controls. -
#761
7133efbThanks @mobeenabdullah! - Load template and playground fonts from packages instead of fetching them from Google Fonts during the build. -
#784
eefb655Thanks @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.
usePaginationowns page and size together, so the resets travel with the state rather than with each caller: a size change returns to the first page, andresetPagecovers 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.useServerTablederives from it rather than restating it. -
#767
9a8d259Thanks @mobeenabdullah! - fix(create-nextly-app): declarepackagesin the generated pnpm-workspace.yaml so scaffolded projects install on pnpm 9 -
#770
dd3eafdThanks @mobeenabdullah! - fix(create-nextly-app): ship the template .gitignore through npm packing, so a new project does not commit its .env -
#797
ec9b4c7Thanks @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
a398047Thanks @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
d011d54Thanks @mobeenabdullah! - Render a table's custom footer beside its pager rather than instead of it.DataTableViewresolved its footer slot aspagination ? pager : footer, on the reasoning that two pagers in one slot is not a composition anyone wants. Butfootertakes an arbitrary node rather than a pager: a caller using it for a selection summary or bulk actions and then adoptingpaginationlost 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
e5e4023Thanks @mobeenabdullah! - Tabs gainTabsList 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
d3e487aThanks @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
parseConfigthat 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
a88d6c5Thanks @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..config.bundled_*.mjs behind that no ignore rule covered, and the next lint failed with a parsing error naming a file nobody wrote. -
#750
36825d4Thanks @mobeenabdullah! - Start both build watchers of@nextlyhq/uion every platform. Thedevscript used a POSIX
background-and-wait, whichcmd.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
02ade17Thanks @mobeenabdullah! - Convertpackages/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
40dfd52Thanks @mobeenabdullah! - fix(nextly): paginate users by user rather than by role-joined rowlistUsers 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
5ff805eThanks @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
5ff805eThanks @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