Skip to content

v0.0.2-alpha.67

Pre-release
Pre-release

Choose a tag to compare

@mobeenabdullah mobeenabdullah released this 18 Oct 13:18
· 646 commits to main since this release
fe3b37e

Before you upgrade: run nextly migrate, then install each configured plugin

Run these against your production database once, after deploying the new packages and before the new version serves traffic:

npx nextly migrate                          # required for every project
npx nextly plugins install <plugin-name>   # once for each plugin in your config

nextly migrate is required for every project, with or without plugins. 0.0.2-alpha.67 adds two core tables, nextly_version_pins and nextly_version_views, and a production server never creates tables itself. Until nextly migrate has run, deleting documents fails with "relation … does not exist", while reading and saving still work.

If your project configures a plugin (for example @nextlyhq/plugin-form-builder or @nextlyhq/plugin-page-builder), a production server refuses to start until the plugin has been installed on that database. It stops with PLUGIN_NOT_INSTALLED and names the command to run. Development only warns. A project made from the blog template before 0.0.2-alpha.67 needs:

npx nextly plugins install @nextlyhq/plugin-form-builder

Projects created with the 0.0.2-alpha.67 blog template run this install in their build script automatically. Installing leaves your existing content as it is, and running it again is harmless. See Installing a plugin.


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

What's changed

Patch Changes

  • #2075 57bcb18 Thanks @nextly-agent! - An update or delete by where under access rules now refuses a where on a
    field the caller may not read in the collection bulk service itself, before
    any row is found or written. The Direct API already refused it; the REST bulk
    routes, which reach the service without the Direct API in front, did not, so
    a caller allowed to write but not to read a field could guess its value and
    learn from which rows changed whether it matched. Callers with
    overrideAccess: true are unaffected.

  • #1874 ce962c7 Thanks @mobeenabdullah! - A class whose id was __proto__ could not be renamed again after a refused
    save. The class manager records which rename of each class is the live one, and
    it kept that record on a plain object keyed by class id, where __proto__ reads
    back an inherited object and a write to it stores nothing. The refused rename
    never released its pending name, so retrying the same name was taken as no
    change at all.

    The record is now a Map, and the pending names are read as the record's own
    entries in both the editor and the class manager panel, so a class behaves the
    same whatever id it carries.

  • #2032 f6b972f Thanks @nextly-agent! - A plugin cleared by clearServices() now reaches every service it was started with during its teardown, including those its context looks up through the container itself, such as ctx.services.jobs and ctx.audit. Those found the container empty, so a destroy that queued final work or wrote an audit entry failed, or its entry was dropped. A second clearServices() before the next registration, or one that arrives while that teardown is still running, no longer takes away the services it needs.

  • #2012 357cd7f Thanks @nextly-agent! - A plugin cleared by clearServices() is torn down when services register
    again, and its destroy can now still reach the services it was started with.
    clearServices() used to empty the container they lived in at once, so a
    teardown that opened a transaction, as ctx.db.transaction does through the
    adapter on SQLite, failed half-way and the new registration went on with the
    old plugin's cleanup unfinished. The plugin docs no longer say destroy runs
    on a hot reload in the author guide's example or the contributing checklist.

  • #2054 4c13e6d Thanks @aqib-rx! - A plugin route that sets csrf: true now checks the origin of a POST, PUT,
    PATCH or DELETE that no session authenticates, before its handler runs. Before,
    such a request was not checked at all, so a guest-facing route, such as a cart
    that keeps its own cookie, accepted a cross-site form post. The request must
    name your site or an origin in NEXTLY_ALLOWED_ORIGINS, as a signed-in write
    must; one naming another origin, or none, is refused with CSRF_FAILED. It
    is not asked for a CSRF token. A session cookie that is expired or malformed
    authenticates nobody, so its caller is checked the same way, and is no longer
    asked for a token it could not hold.

    Only an Authorization: Bearer token, with the scheme in any case, now exempts
    a request no session authenticates from the check, whether or not the token is
    a valid key. A request a session authenticates is checked as the session's,
    whatever header it also carries. Any other
    scheme, such as the Basic, Digest, Negotiate or NTLM credentials a browser
    attaches on its own, no longer does.

    The plugin route documentation now lists which callers each csrf setting
    checks.

    Compatibility: a client that writes to a public route with csrf: true
    without a session or a Bearer token, such as a server job, must now send
    Origin with your site's origin or an allowed one, or it is refused. A route
    meant for webhooks or other server callers should leave csrf unset, which
    still checks no anonymous write. No first-party plugin route sets csrf: true.

  • #2214 27b47fe Thanks @nextly-agent! - Retention never removes a version a feature has pinned. A feature registers
    itself as a pin holder once, then points named slots at versions of a document
    (versionPins.holder(name).pin(tx, { ref, slot, versionNo })); moving a slot
    frees the version it left, and a version stays while any slot of any feature
    points at it. registerVersionPinHolder, versionPins and their types are
    exported from nextly as experimental, and the versions docs describe them.
    Pins are kept in a new core table, nextly_version_pins, which nextly migrate creates on an existing database, and they go with their document or
    Single when either is deleted. A pin takes the document's write lock, so it
    cannot slip past a save's retention pass.

  • #2163 b7948bb Thanks @faisal-rx! - Whether a caller may filter, sort, group or search on a field now follows
    whether the field's read rule lets that caller read it. Before, any field
    with a read rule was refused for every caller, the ones the rule allows
    included.

    The rule is asked for the caller with no entry, since a query has none yet:

    • A rule that allows the caller outright (by role, permission or
      identity, such as ({ req }) => req.user?.role === "admin") now lets that
      caller filter, sort and group on the field, and a collection's search
      matches on it for them.
    • A rule that refuses the caller is refused as before, with a 400
      naming the field as written: FIELD_NOT_FILTERABLE, FIELD_NOT_SORTABLE
      or FIELD_NOT_GROUPABLE. A collection's search leaves the field out.
    • A rule that needs the entry to decide, such as
      ({ req, data }) => data?.owner === req.user?.id, cannot allow without one,
      so the field cannot be queried by anyone, its owner included, and search
      leaves it out. A filter or an ordering on it would reveal its value on the
      entries where it is hidden. To query by ownership, filter on the owner field
      itself.

    A widget data source still offers a group key only when no read rule
    guards the field, since one source serves every caller.

  • #2140 1db672e Thanks @faisal-rx! - A filter that is not well formed is now refused instead of being run without
    its bad part. Before, each form below was dropped on the way to the database,
    so the read matched more entries than the filter stated, and a filter that
    narrowed by owner or team could quietly return every entry. Through REST each
    answers 400 with a VALIDATION_ERROR naming the problem at where.<path>;
    through the Direct API, find, count, grouping, timelines and the
    bulk-by-query writes throw the same typed NextlyError.

    Newly refused, and what to write instead:

    • An unknown operator ({ title: { equal: "a" } }, or startsWith) —
      FILTER_UNKNOWN_OPERATOR. Use one of equals, not_equals,
      greater_than, greater_than_equal, less_than, less_than_equal,
      like, contains, search, in, not_in, exists, near, within.
    • An unknown field, or one with no column on the collection's table, such
      as a many-to-many relationship (FILTER_UNKNOWN_FIELD). Check the field's
      name; for a many-to-many relationship, read the related ids first and filter
      on id.
    • A dot path that is not into a component ({ "author.name": … },
      { "meta.key": … }) — FILTER_UNFILTERABLE_PATH. It used to compare the
      whole author or meta column with the value. Look the related entry's id
      up first and filter on the relationship field: { author: { equals: id } }.
    • undefined, as a field's value or an operator's —
      FILTER_UNDEFINED_VALUE. Leave the condition out.
    • An empty in ({ id: { in: [] } }) — FILTER_EMPTY_IN. It matches no
      entry, so skip the query, or leave the condition out if you meant "any".
      An empty not_in still excludes nothing.
    • An and or or that is not a list of filters, or a list with a gap or
      a non-object in it — FILTER_INVALID_LOGICAL / FILTER_NOT_AN_OBJECT.
      Write and: [{ … }, { … }].
    • An operand of the wrong type — FILTER_INVALID_OPERAND: an object or
      list given to equals (use in for several values), a value that is not a
      list given to in or not_in (write ["value"]), null given to any
      operator or as a field's value (write { exists: false }, or
      { exists: true } for not_equals: null), a list given as a field's value
      (write { in: [...] }), exists with anything but true or false, and a
      near or within that does not parse. A geo operand now parses only in its
      documented form: an unknown unit (it used to mean metres), parts beyond the
      last one the format names (they used to be ignored), or a number with
      trailing text (5abc used to read as 5) is refused.
    • An empty condition ({ title: {} }) — FILTER_NO_OPERATOR. Name an
      operator or leave the field out.
    • A malformed language filter (_translated without a locale string
      and a known state, or anywhere but the top level) —
      FILTER_INVALID_OPERAND / FILTER_MISPLACED_KEY.
    • A component field or a geo operator inside or — FILTER_NOT_IN_OR.
      Both are applied as a condition every entry must meet, so inside or they
      turned the alternative into a requirement. Move them to the top level or
      into and.
    • A REST where that is not valid JSON, or not an object —
      FILTER_INVALID_JSON / FILTER_NOT_AN_OBJECT. It used to read as no
      filter at all.
    • A REST request carrying both where= JSON and where[…] parameters —
      FILTER_GIVEN_TWICE. The bracket conditions used to be ignored. Send one
      form.
    • A REST bracket key that is not bracketed segments
      (where[title]equals=a), that names __proto__, whose list index is 50 or
      more (the most conditions a filter may have), that nests under a value
      another parameter gave, that mixes list indexes and named keys at one level,
      whose index has leading zeros, or that repeats a condition already given —
      FILTER_INVALID_KEY. A __proto__ field key in a JSON where is refused
      as an unknown field. Fields named constructor or prototype filter as any
      other field does.
    • A REST filter on the owner column (created_by, createdBy, any
      spelling that resolves to it such as CreatedBy, or a dotted form) — FIELD_NOT_FILTERABLE. It used to be removed silently, so
      or: [{ createdBy: … }] matched every entry. The column is never returned,
      so it cannot choose rows either.

    Two meanings are corrected rather than refused: an empty or now matches no
    entry (it matched every entry), and an or holding an empty member such as
    {} now matches every entry, as a condition that is always true does.

    The REST bracket form also works again. where[title][equals]=alpha was read
    into a field named title]equals, which matched no column and was dropped, so
    every bracket filter returned all entries. A comma now splits values only for
    in and not_in, where a single value becomes a one-item list:
    equals=Hello, world compares with that text, and a near point reaches the
    geo filter intact.

  • #2059 2a41096 Thanks @nextly-agent! - Database adapters can tell whether the calling async flow is inside a transaction that has not finished yet, with transactionOpenInFlow() (@experimental). Each flow gets its own answer, never the connection's: on SQLite, another request's open transaction does not make it true. It is true inside transactions that core's services open through Drizzle on PostgreSQL and MySQL, as well as inside the adapter's own transaction().

  • #2004 755d99c Thanks @nextly-agent! - A fresh install no longer starts without the foreign keys a plugin's or the
    app's table declares to a collection's, Single's or field group's table. If
    one cannot be created, the first boot stops and names the table, the key and
    its target. The first boot also no longer prints a warning for each of those
    keys before creating them.

  • #2076 6846b88 Thanks @nextly-agent! - On SQLite, an effect held until an enclosing transaction commits (an after
    hook, an event, a cache flush) now runs in the async context of the write
    that registered it. It used to run in the context of the code that ran the
    outer transaction, so the caller pinned around the write was gone. In
    particular a scoped API key's scope was lost: a hook's nested gated call was
    judged by the key's owner, a super-admin among them, instead of the key. The
    effect still runs outside the finished transaction. PostgreSQL and MySQL
    were not affected; they run such effects at once.

  • #2127 43f7c5e Thanks @nextly-agent! - A dashboard with more cards than one save may carry can be saved again

    When a person regains access to many cards at once, their arrangement can hold more than one save may carry, in number of cards or in size. Saving now leaves out cards they have removed, latest first, until it fits. Cards still on their dashboard are never left out.

    nextly/config now also exports layoutSizeProblem and MAX_LAYOUT_BYTES, the layout endpoint's own check of whether a submission fits.

    Retired dashboard widgets now merge only into the same card of their successor, as the widgets specification describes, and a widget retired twice reaches the widget that exists now. This release retires no widget, so no dashboard changes yet.

  • #1972 c753694 Thanks @nextly-agent! - A large backlog of due releases settled more slowly than it had to. Each drain
    pass decided which due releases it could mark published by looking every one
    of them up in the list of all due releases, so the decision grew with the
    square of the backlog: about two to four seconds at twenty thousand due
    releases, an imported schedule's size, taken out of a pass bounded to twenty or
    twenty-five.

    The pass now looks them up in a set built once, so the decision grows with the
    backlog. Which releases may settle, and in what order, is unchanged; a pass
    only reaches further into a large backlog before its deadline.

  • #1876 14f98f7 Thanks @mobeenabdullah! - A link saved in a pattern now points at the element the page actually shows.
    When a selection held a visible renamed element and a hidden copy with the
    same id (hidden by a visibility condition), a link from an unrelated block could
    keep the old id, which nothing renders once the hidden copy is left off the
    page. The visible element now decides where such a link points. A hidden
    element decides only when nothing visible carries that id, so a link and its
    target stay together when the condition later shows it. Hidden elements still
    get their own ids put back.

  • #2234 841a74e Thanks @nextly-agent! - Adding Nextly to an app reached through a symbolic link (the project folder itself is a link) writes every file again. nextly.config.ts, directly in the project, was taken for a file written through a link and skipped, while the routes that import it were written. The project folder is now left out of that check for every file alike, and the folders below it are still checked.

  • #2280 b2cf477 Thanks @nextly-agent! - A localized single can be read in production. At a production boot, a code-first single was registered without its localized setting, so reads looked for its translated fields on its own table, where a migrated database does not keep them, and every read of a localized single (pages, the REST and Direct API, dashboard widgets) failed with "no such column". The dev server registered it correctly, which hid it. Singles are now registered as collections are.

  • #2015 3bf9a8e Thanks @nextly-agent! - A boot now refuses to start whenever a plugin's or the app's table that holds no
    rows yet lacks its foreign key to a collection's, Single's or field group's
    table, not only on the boot that created the tables, so a restart after a
    partly applied first boot no longer serves the table without its key. A table
    that holds rows is left to nextly db:sync and migrations. The first boot again
    attempts, and reports, a key to any other table that does not exist yet.

  • #2152 810cc77 Thanks @nextly-agent! - Adding Nextly to an app inside a monorepo now installs with the workspace's package manager, and approves its build scripts at the workspace's root. The workspace is the nearest one above the app, up to its repository's root, whose pnpm-workspace.yaml packages or package.json workspaces lists the app; its lockfile names the manager, or pnpm when its pnpm-workspace.yaml lists the app before the first install. An app inside a pnpm workspace no longer gets npm install from an npx launch, and an app no workspace lists keeps the manager that launched the generator, whatever lockfile sits above it. A new project keeps the manager that launched it. When the app already approves a specific esbuild version in pnpm's settings, that rule is kept, and the generator's note, which a failed install now repeats, says to approve the version pnpm names if the install then stops on another one.

  • #2146 c3d368c Thanks @nextly-agent! - A new project starts Nextly when its server starts, so /api/health answers healthy on a freshly started server without waiting for other traffic, and code that reads through the nextly proxy finds it running. The blank and blog templates ship an instrumentation.ts that does it, and adding Nextly to an existing app writes one, or, when the app already has one, leaves it alone and prints the lines to add. Before Nextly has started, /api/health answers 503 with the generic error and logs, once, how to start it.

  • #2153 028c4a0 Thanks @nextly-agent! - Every admin user now has a personal list of notices: things that happened to
    their work that they should know about. A notice is for one person only, is
    stored once per occurrence, keeps a snapshot of what it is about so it still
    reads after that is deleted, and records when it was read. Read them with
    GET /api/notices, count them with GET /api/notices/count, and mark them
    read with POST /api/notices/read, all for the signed-in person (API keys are
    refused); or from server code with nextly.notices.find(), count() and
    markRead(). Notices are kept as long as the activity log keeps its records,
    and are deleted with their person. Core features add their own kinds of notice
    through one registered, typed kind; plugins cannot add kinds yet. All of this
    is experimental.

    Run nextly migrate to create the nextly_notices table on an existing
    installation. Until then lists are empty, nothing is recorded, and one warning
    names the table and the command.

  • #2112 07bf1f9 Thanks @nextly-agent! - A plugin can contribute a full-screen view of a document, at its own address
    under the document's: /admin/collections/<slug>/<id>/<path>, or
    /admin/singles/<slug>/<path> for a Single. The admin keeps the document's
    loading, soft lock, recovered-work offer, leave guard and save shortcut around
    the view, so the plugin implements none of them. Declare a view for one
    collection under contributes.admin.views[slug].documentViews, or for every
    collection and Single with a top-level field of a type the plugin contributes
    under contributes.admin.documentViews with fieldType. navigateTo in
    @nextlyhq/plugin-sdk/admin moves to another admin page and refuses any other
    address. PluginDocumentView, PluginFieldTypeDocumentView,
    DocumentViewProps and navigateTo are experimental. Startup is refused when a
    view's path is malformed, reserved (versions, api), claimed twice on one
    document, or offered on a field type its plugin does not contribute.

    nextly generate:types now writes the plugin component import map, or removes a
    stale one, in an app that defines no schema in code. Such an app, whose schema
    is made in the Schema Builder, rendered empty plugin pages and settings
    components in a bundled admin, because the map was skipped with the types.

  • #2000 3b7c77f Thanks @nextly-agent! - A plugin can no longer declare a hook point under a prefix core's own seams use
    (email., admin. and collections.): boot now fails with
    hook-point-reserved, naming the plugin and the point. A plugin whose slug is
    admin could otherwise declare admin.nav as a decision, and core's own
    admin navigation filter would then be refused on every request.

  • #2039 e78ff48 Thanks @nextly-agent! - A plugin can no longer turn an account into the kind core makes for a person another
    system has verified: active, verified and without a password. Through
    ctx.services.users, an update that would leave an account that way is refused with
    FORBIDDEN and changes nothing: verifying an active account that has no password,
    activating a verified one that has none, both at once, or reactivating one core made for
    a verified person. Core makes those accounts only when one of the app's login plugins
    hands it a sign-in. Such an account still takes a plugin's other edits, and a plugin may
    deactivate it or give it a password, but may not move it to another address unless the
    same write gives it a password. An active account with no password that is not
    verified stays allowed.

  • #2085 b2e486d Thanks @nextly-agent! - A plugin's email to one of its signed-in people can now name them:
    ctx.services.email.send and sendWithTemplate take person, by kind and
    id. The delivery record of the message keeps the person's kind and id, on the
    row of the to address, in new principal_kind and principal_id columns of
    email_deliveries. A person that cannot name one, or names a kind no
    running plugin registers, is refused before anything is sent.

    ctx.principals.reportDeleted now also erases, in the same transaction, the
    address digest on every delivery record naming the person, and names them
    there by the keyed digest of kind and id in place of the id. Every record
    written for the person afterwards, the email telling them their account is
    gone included, is written without an address digest, even when it is sent
    inside the report's transaction. Records of anyone else at the same address,
    a staff user's among them, stay as they were.

    Every delivery record is written through one insert, which takes the lock a
    deletion report waits on; a test holds any new writer to it. nextly migrate
    and boot add the columns and their index on all three databases, and rewrite
    no row.

  • #2073 54292bd Thanks @nextly-agent! - A plugin can limit attempts on keys it chooses, before the work they guard runs (@experimental). It declares named rules in contributes.limits: rolling windows, or a growing wait after attempts in a row. The app refuses to start on a rule outside the documented bounds. The plugin then reserves through ctx.limits on 1 to 16 entries, each a rule and a key. A reservation is decided and counted atomically in the selected limit store, so instances sharing it admit no more than the rules allow. Each entry chooses whether it can refuse (enforce) and when it counts (count), and gets its own verdict back. release, reset, check and clientKey are there too. A store that can't answer gives unavailable, never an admission. Keys are stored only as keyed hashes, namespaced to the plugin. The types are exported from @nextlyhq/plugin-sdk.

  • #2003 736748a Thanks @nextly-agent! - ctx.auth.completeLogin now refuses a plugin's strategy name that is only its
    slug and a colon (acme-google-auth:), with no name for the method after it.
    Such a login was recorded on the audit trail as the plugin alone, so a plugin
    with more than one way in could not be told apart on the rows. Name the method
    after the colon (acme-google-auth:google); plugins that already do are
    unaffected.

  • #2067 07a3cf1 Thanks @nextly-agent! - A plugin-defined signed-in person can now read and write collections, under
    access rules and nothing else. Collection and field rules receive the person as
    principal, beside a user that is null, and collection hooks receive them
    too. A person is admitted only by a code-defined rule that names them, or one
    set to true: a collection with no rule for the operation refuses them,
    database permissions and the super-admin bypass never apply, and a person can
    never publish. Every other caller's rule receives principal: null.

    Collection and Single operations on the Direct API, and a plugin's
    ctx.services collection calls, take a principal option to run as a person.
    The call is refused before it runs if it also asks to skip access rules or
    the filterable-field check (frameworkFilter), names a staff user or an API
    key, or names a person core did not make; any other operation refuses the
    option, and it cannot be a Direct API instance default. findSingles made
    as a person lists, and counts, only the Singles the person may read. A document created as a person has no owner.
    nextly.principals.byId and ctx.principals.byId find a person through the
    kind's lookup, checked as a resolver's answer is.

    An update or delete by where made under access rules (a person's, or any
    call with overrideAccess: false) now refuses a where on a field the
    caller may not read before it writes anything, as a read already did; the
    rows it touched used to answer whether a guessed value matched.

    A dev reload now installs the kinds of signed-in person it reads only once it
    commits, and a kind's attributes may no longer be optional.

    AccessControlContext and a field rule's arguments gain a required
    principal, so code that builds either by hand adds principal: null.

  • #2078 b9493fe Thanks @nextly-agent! - A plugin can now name a person it signs in on an audit row, and erase them when
    it deletes them. ctx.audit.write takes actorPrincipal and targetPrincipal,
    by kind and id, kept in new columns of their own. Such a row keeps its network
    address, browser and metadata. A person's id in actorUserId or targetUserId
    is still stored as an erased account's, as before.

    ctx.principals.reportDeleted(person, { tx }) erases a person from the audit
    trail in one transaction, the plugin's own when it passes tx. Core clears the
    address and browser on every row the person acted on, and the plugin metadata
    on every plugin row naming them. It names the person on those rows by a keyed
    digest of kind and id, in place of the id, and keeps only that digest and when,
    in the new nextly_deleted_principals table. Every row written about the
    person afterwards is written already erased. A report made inside an open
    transaction without passing it is refused.

    The digest's key is made once and kept sealed under NEXTLY_SECRET. After a
    rotation, keep the old secret in NEXTLY_SECRET_PREVIOUS until the app has
    started once with both, as for plugin settings.

    Every audit row now records which kind of caller acted, in actor_kind:
    staff, apiKey, core, anonymous, or the person's kind. Rows written
    before this release keep NULL there and read as before. nextly migrate and
    boot add the new columns, indexes and table on all three databases, and
    rewrite no row.

    RequestActor gains the principal type for a write made as a person. It
    carries the person's kind and id and no id, so the activity log, which
    records staff only, records no row for it. Webhook deliveries still report such
    a write as system, as they did before.

  • #2083 d5fee41 Thanks @nextly-agent! - A plugin's audit event naming a person of a kind no running plugin registers,
    a misspelled kind for instance, is now dropped with a warning, as one naming
    nobody is. No deletion report could reach such a row, so the network address,
    browser and metadata it kept would have outlived the person.

    The app now also refuses to start on a kind of signed-in person whose full
    name, <plugin slug>:<name>, is longer than the 191 characters an audit row
    stores it in. On PostgreSQL and MySQL every audit write naming such a person
    failed at the column, and the row was lost with only a log line.

  • #2087 a8f3251 Thanks @aqib-rx! - A plugin settings request made with an API key now counts once against the
    key's rate limit. The settings route authenticated the key, then authenticated
    it again to check its permission, so each read or change of a plugin's
    settings spent two of the key's requests, and a key with one request left was
    refused with a 429. The permission is now judged on the caller the route
    already authenticated.

  • #2060 e4e5088 Thanks @nextly-agent! - A plugin can now sign in people who are not staff, such as a shop's customers
    or a site's members, without them ever reaching the admin. It registers its
    own kind of signed-in person in contributes.principals: a name, the
    attributes the kind exposes with their types, and a resolver that says who is
    signed in from the plugin's own session. Core names the kind
    <plugin>:<name>, and the app refuses to start on a kind whose name,
    attributes or resolver cannot be used, or whose name another plugin's kind
    already holds.

    Core calls a kind's resolver only where an entry point asks for that kind, and
    at most once per request. A plugin route asks with principal: { kind }. Its
    handler gets the person as ctx.principal and never staff: the route reads
    neither the staff session nor an API key, and ctx.auth.currentUser answers
    no user there. A route that requires the kind refuses a request with nobody of
    it with the same 401 any route gives an unauthenticated caller. A person's
    writes take the route's cross-site check as a staff session cookie's do,
    unless the request carries a Bearer token. principal cannot be combined
    with public or requiredPermission. Server code asks with
    ctx.principals.fromRequest or nextly.principals.fromRequest, which answer
    nobody to a write from another site. nextly plugins info lists each plugin's
    kinds.

    The surface is @experimental until the feature is complete.

  • #2049 9064005 Thanks @nextly-agent! - A plugin's update to an account core made for an externally verified person no
    longer fails when it carries the account's own address or verification date
    unchanged, as a full-object update does. Before, the unchanged address was
    judged as a move to another address and refused with FORBIDDEN, and the
    unchanged date was judged as a fresh verification, refused for the same reason
    and, on other accounts, restamped as vouched for by the plugin.

    An update that carries an account's address unchanged no longer writes it, so a
    stale update cannot put back an address another request has since moved the
    account from. An address stored in mixed case before addresses were lowercased
    is still rewritten in lowercase, so sign-in can find it. When an address moves,
    a verification date carried with it is recorded as the updater's vouching, not
    kept as whoever verified the old address.

    The update schema now takes a verification date as a Date or an ISO 8601
    date-time string, and refuses any other string with VALIDATION_ERROR. A string
    failed every update before, and "0" would have parsed as the year 2000.

    When a plugin's update is judged, a stored password counts as one when it is a
    hash core made, or, where the app passes its own passwordHasher to
    getNextly, whatever that hasher stored.

    The plugin services documentation now says that ctx.services.users.create
    refuses a missing password with VALIDATION_ERROR once an account exists, and
    with FORBIDDEN on an empty install.

  • #2005 24f9dfd Thanks @nextly-agent! - A write another site posts to a public plugin route with the session cookie is
    now recorded as a csrf-failed audit event naming the plugin and the route
    when the handler asks ctx.auth.currentUser who is signed in. The handler
    already got no user for such a write; the operator can now see the attempt,
    as they can when an authenticated route refuses one.

  • #2042 b2e4ce0 Thanks @nextly-agent! - When an app runs without the access-control service, a populated relationship now answers
    a signed-in person as a direct read does, from the person's permission grants, instead of
    returning every related row. Whether the caller may read related users is now asked once
    per read, however many relationships point at users, rather than once per field.

    An anonymous visitor no longer receives a populated user through a relationship: no
    visitor can read a user directly, and a user row carries the person's address. Visitors
    keep populated media, which public pages embed. A page your server renders with the
    Direct API reads as the system by default and still shows authors.

  • #2020 5ba07e9 Thanks @nextly-agent! - A relation a plugin's or the app's table declares is now checked when the app
    starts, once every table is registered. A relation to a Builder-made collection
    still resolves; one naming a table or column that does not exist stops the boot
    with NEXTLY_SCHEMA_RELATION_UNRESOLVED, naming each such relation, instead of
    failing the first relational query with an internal error. In development it is
    reported once the boot's schema apply has run, without stopping the dev server.

  • #2186 d1fe962 Thanks @nextly-agent! - A release now has a kind: release, the default, or single, a document
    scheduled on its own. Pass kind to nextly.releases.create() or
    POST /api/releases, and read it on every release. A single-document release
    holds exactly one document. The database refuses a second one, even when two
    requests add documents to it at once, with a conflict that says to use an
    ordinary release instead.

    find(), count() and GET /api/releases take a kind filter: release leaves
    out single-document releases and single lists only them. It filters on the
    stored kind, never on how many documents a release holds.

    nextly migrate adds the kind column (existing releases read as release) and
    a single_slot column on release documents with its unique index. No row is
    rewritten. kind is experimental.

  • #2185 bd15d17 Thanks @nextly-agent! - A release can be published now: POST /api/releases/:id/publish and
    nextly.releases.publishNow({ id }) run a draft or scheduled release at once,
    with the same checks as scheduling, by moving it to the current time (unless
    it is already due) and starting the job runner's releases run straight away,
    and answer the release as it then stands. When a releases run is already in progress, the release goes
    out with it (or with the next one) and the answer says it is still scheduled.
    Each release in a list also says whether the reader may publish it now
    (can.publishNow). Both are experimental. The capabilities type,
    ReleaseCapabilities, is now exported from nextly/schemas, and the admin
    reads its release controls from it.

    Behaviour change: a cancelled release is now final, like a published one.
    It can no longer be scheduled again, and documents can no longer be added to or
    removed from it; create a new release instead. A stopped release can still be
    fixed and scheduled again.

  • #2178 c02c9f9 Thanks @nextly-agent! - Adding a Single to a release no longer creates its row. The check that the
    document exists read the Single the ordinary way, which creates a Single's row
    the first time it is read, so a Single that was never saved was accepted, and a
    blank row was left behind even when the add was then refused. A Single that was
    never saved is now refused as not found, and nothing is written.

    The check also always reads as the caller: a Direct API instance's default API
    key can no longer stand in for a caller who brought no key of its own.

  • #2145 a118daf Thanks @nextly-agent! - A release now checks a document the same way whichever way it is added. The
    Direct API's addMember used to skip what the REST route checked, so a server
    caller could add a document that does not exist, one in a collection without
    drafts, or a Single by an id that is not its own. Those are now refused from
    every caller, with the same errors as over REST, and a Single is recorded by its
    own stored row. A trusted call (overrideAccess, the default) skips only the
    read permission.

    Finding the releases that are due is now one query on the release's state and
    time, instead of reading every scheduled release and filtering in memory, so a
    long list of future releases no longer slows each pass of the drain.

  • #2195 ba18126 Thanks @nextly-agent! - A Single created in the admin can be added to a release from any server
    process. The release target check looked for the Single's row without first
    registering its table in the process handling the request, so a worker that
    booted before the Single existed refused a valid add with an error. It now
    resolves the Single the way every Single read does.

  • #2209 c75ce69 Thanks @nextly-agent! - A release moves only from the state it was judged in. Every change to a
    release (scheduling, cancelling, stopping, publishing, and adding or removing a
    document) now advances its revision, and scheduling compares against the one it
    read: a document removed while a release is being scheduled makes the schedule
    refuse, rather than leaving a single-document release scheduled with nothing.
    A release's capabilities now say what a single-document release allows: no
    scheduling while it is empty, no second document, and no removing its document
    once scheduled.

    For code calling ReleasesRepository directly: scheduleRelease's
    expectedRevision (added as optional with Publish now) is now required, and
    from no longer has a default. A schedule fenced only on state cannot tell
    that a release changed under it, so every scheduling move must name the
    revision it read: read the release, then pass its revision.

  • #2215 96686eb Thanks @nextly-agent! - Adding Nextly to an existing app no longer writes a media or health route where a route handler the app already has answers that URL through a dynamic segment, such as app/api/[...rest]/route.ts or app/api/[name]/route.ts: the new, more specific route would have taken those requests from it. The route is reported, with its content to add, instead. Pages reached through a dynamic segment, such as a site's catch-all page, still leave the routes free to be written.

  • #2269 0fdc420 Thanks @nextly-agent! - A project from create-nextly-app whose config registers a plugin, such as the blog with its newsletter form, now installs that plugin in its build script, after nextly migrate and before next build. A production boot refuses a configured plugin that was never installed on its database, so a fresh blog project built for production answered every request with a 500 until nextly plugins install had been run there by hand. Installing again on each build is safe; each run adds one row to nextly_schema_events.

  • #2057 9781341 Thanks @aqib-rx! - A plugin's entity transform can no longer change what isn't its to change.

    A transform hands back a whole new definition of a collection, Single or
    component, so nothing stopped it from setting an option the entity's own
    definition is refused for, such as a collection's db.idType of "serial",
    or from dropping another plugin's column. Each transformed entity is now
    validated as its definition would be, and a refusal names the plugin.

    A plugin transforming another plugin's entity must now list that plugin in
    dependsOn, so the owner can't be uninstalled from under the change. It can't
    move the entity to another table, and it can't remove a field, or change how
    one is stored, unless it declared that field. An access rule, hook, validator
    or admin setting a third plugin contributed stays unless the transforming
    plugin depends on that plugin too. A plugin that transformed another's
    collection without declaring the dependency stops at boot until it does.

  • #2077 209b10c Thanks @nextly-agent! - A two-factor challenge is answered once on every instance. The first correct answer now marks its sign-in answered in the database, in a new table nextly_spent_challenges, by one write only one caller can win, committed before the session is issued. Any later answer to the same pending token is refused, on whichever instance it reaches, before the challenge's resolve runs, and two correct answers at once mint one session. This holds whatever rateLimit.store is set to. The one-shot mark used to live in the rate-limit store, so with the default memory store an answered challenge could be answered again on another instance. A challenge pending across the upgrade is answered against the new mark.

    The attempt budget is counted in the limit store rateLimit.store selects, so with "database" wrong answers on every instance spend one budget. An app's own store of the earlier RateLimitStore contract keeps serving it. Its keys, which name an account, are stored only as keyed hashes, so a budget pending across the upgrade starts again. When the store can't be reached, or doesn't answer in time, a two-factor answer is refused with "Please try again shortly." (503) and no session is minted. The boot warning about a per-instance budget no longer appears when the database store is selected or rateLimit.singleInstance is set. Expired marks are removed as new ones are written, an hour after their token's end, so an answer let through before the end always meets them.

    AuthRouterDeps gains two optional members, spentChallenges and limitStore, so deps assembled by hand keep working: without spentChallenges, the mark stays in the rate-limit store as before. While an app's own store of the earlier contract is configured, the earlier release's mark is also kept there, so during a rolling upgrade instances of either release refuse a challenge the other settled. nextly/auth exports databaseSpentChallenges and SpentChallenges for such a caller to opt into the database's marks.

  • #2188 c731d75 Thanks @nextly-agent! - Two public types now match what Nextly accepts and returns. Code that calls them compiles as before. A hand-written stand-in for the Nextly interface, such as a test double, must now return token and exp from login, as the real one always has.

    • A job defined with a typed input, defineJob<{ cursor: string }>(...), now goes into the config's jobs list, or a plugin's contributes.jobs, without a cast. Both lists now take any job, whatever input its handler takes. A value that is not a job is still refused, and a handler is still typed for its own input only, so it can't be called with another job's input.
    • login() on the Nextly instance from getNextly() is now typed as LoginResult, the user with the session token and its expiry exp, which is what it already returned. The documented result.token now type-checks.
  • #2197 abe6ace Thanks @nextly-agent! - A comparison no longer offers a withdrawn version as what is published. The
    published baseline, and the comparison that opens on the current draft
    against what is published, now apply only while the language (or the part of
    the document with no language) is actually served: after an unpublish, or
    while a translation has never been published, published is refused with
    NO_BASELINE and the default opens on the version before. Whether a
    language is live is now decided in one place for every path, from the main
    row's status and the translation's own, and a translation's own status is
    read through one shared reader.

  • #2086 39cd056 Thanks @nextly-agent! - The accounts plugin names the account on every email it sends, so core erases the email's delivery record with the account when the account is reported deleted.

  • #2079 bd8a2e9 Thanks @nextly-agent! - The accounts plugin runs on core's own helpers: its password, one-purpose secret, opaque secret and limiter types come from the plugin SDK, and it declares its limit rules to core, which checks each when the site starts. It now refuses to start on a core that does not lend every helper it is built on, and, in production, on limit counts each instance keeps apart.

  • #2068 6d1883d Thanks @nextly-agent! - Add @nextlyhq/plugin-accounts, site accounts for people who are not staff: sign-up with address verification, sign-in with per-address and per-client waits and known browsers, password reset, the holder's password and address changes, account deletion, the account kind of signed-in person, and REST endpoints for each, built on the account helpers core lends plugins.

  • #2084 1c5fcee Thanks @nextly-agent! - The accounts plugin reports each account it deletes to core, in the transaction that deletes it, so core erases the account from the audit trail with the deletion. Staff actions name the account as the audit row's target person rather than in its metadata, and the plugin refuses to start on a core that does not lend it a way to report deletions.

  • #2082 b2393fe Thanks @nextly-agent! - The accounts plugin's stand-in limiter gives a count back in the run the caller's reservation names for it, as core does, so a copy carrying stale runs gives nothing back.

  • #2081 a18fade Thanks @nextly-agent! - The accounts plugin's testing stand-ins keep core's contract: the limiter gives a count back only to the run it was counted in and refuses a malformed release before it reports a store outage, and one-purpose secrets never mint a code that repeats the live one it replaces.

  • #2134 594d727 Thanks @nextly-agent! - Adding Nextly to an existing Next.js app now installs everything it needs and finishes, on npm, pnpm, Yarn and Bun. The app gets esbuild as its own dev dependency, so nextly.config.ts can be read and code-first collections load; before, npm left it nested under another package where Nextly could not find it. The install uses the package manager the app's lockfile names rather than the one that launched the generator, so npx create-nextly-app in a pnpm app no longer runs npm over pnpm's install.

    On pnpm and Bun, the install scripts of the native packages Nextly brings (the SQLite driver, esbuild and sharp) are approved before the install, in the one place the app's own version reads them and at its workspace root in a monorepo: permissions when pnpm-workspace.yaml has that section (pnpm 12.11+), allowBuilds (pnpm 10.26+), onlyBuiltDependencies (pnpm 10.6 to 10.25), pnpm.onlyBuiltDependencies in package.json (pnpm 10.0 to 10.5), and Bun's trustedDependencies when the app keeps that list. pnpm refused the install until then and the generator stopped with no config or routes written; Bun skipped the SQLite driver's install script and the app answered 500. Only Nextly's packages change: any approval or denial the app already wrote, version-scoped ones included, is kept, pnpm's own placeholder is replaced, and each change is printed. An esbuild the app already declares is left as it is. A project set up this way with an earlier version can add esbuild as a dev dependency and approve these packages the same way, then install again.

    When an install fails, the generator now names the exact command to run to see why.

  • #2189 5eaabac Thanks @nextly-agent! - An admin page left open while the app's root layout is edited during next dev no longer fills the console with "Cannot read properties of null (reading 'removeChild')". The admin used to remove the host app's favicon links, which React owns, to show its own; it now switches them off and back without removing them, so React can keep updating <head>.

  • #2173 fe36198 Thanks @nextly-agent! - The admin no longer writes its theme onto the host app's <html>, so an app whose root layout is the one create-next-app writes no longer logs a hydration mismatch on every admin page. The theme, light, dark or system, is applied to the admin's own containers, its dialogs and menus included, before the first paint, so a dark admin no longer flashes light on load. Native controls such as scrollbars and inputs follow the admin's theme.

    ThemeProvider and useTheme from @nextlyhq/admin keep their names and the nextly-theme storage key, and a ThemeProvider inside another still defers to the outer one. ThemeProvider now accepts defaultTheme, storageKey, enableSystem, forcedTheme and nonce; next-themes' other props, such as attribute and disableTransitionOnChange, are no longer accepted, since the admin no longer depends on next-themes. useTheme returns theme, setTheme, resolvedTheme, systemTheme, themes and forcedTheme as before.

    A site with a nonce-based Content Security Policy passes its nonce to RootLayout (<RootLayout nonce={nonce} />) or to ThemeProvider, so the admin's small pre-paint script is allowed to run.

  • #2181 6eec099 Thanks @nextly-agent! - @nextlyhq/admin exports ThemePaintScript and the ThemeProviderProps and UseThemeProps types. A host that puts ThemeProvider around admin content of its own renders <ThemePaintScript /> first inside its .nextly-admin element, so a saved dark theme shows from the first paint. With enableSystem={false} and no defaultTheme, the admin now defaults to light rather than following the system setting.

  • #2182 9c76108 Thanks @nextly-agent! - The admin's create-user and edit-user forms now accept exactly the passwords the server accepts. Any symbol counts, not only @$!%*?&#., and a password over 128 characters is refused before it is sent. Every rule a password misses is listed, not only the first. Choosing "Set a password now" for a new user now says that they choose a new password the first time they sign in, which the server already requires.

    Setting a user's password through an update (the admin's edit form, the REST route or the Direct API's users.update) now stores the password exactly as given. It used to trim spaces from its ends before hashing, while account creation and sign-in did not, so a password whose symbol was a leading or trailing space could not be used to sign in. The admin's edit form no longer trims it either. A value of only whitespace still leaves the password unchanged. Existing password hashes are unaffected: only passwords written from now on change.

  • #2201 40ce55b Thanks @nextly-agent! - nextly/config now exports the dashboard's widths (WIDGET_WIDTHS, isWidgetWidth,
    widthFromSize and the WidgetWidth type), and the admin reads them instead of keeping its own copy.

  • #2041 8d6a0cb Thanks @nextly-agent! - The afterDrizzle escape hatch's refusals now point at what to use instead,
    and the documentation lists every construct it refuses.

    A check constraint or a foreign key returned by the hook is refused with a
    pointer to checks or foreignKeys on defineTable, an expression index to
    indexes, and an enum column to col.enum(). A partial index's refusal no
    longer promises a later release: partial indexes are refused on every dialect,
    because MySQL cannot build one.

  • #2100 ca866a0 Thanks @nextly-agent! - The AGENTS.md a new project or plugin receives names only exports that exist: definePlugin comes from @nextlyhq/plugin-sdk, not its /admin entry, and every sample imports the functions it uses. The generated-types stub no longer mentions a nextly dev command.

  • #2259 2cfea22 Thanks @nextly-agent! - In an existing .env.example, a credential is flagged when its first word only begins like a placeholder word (enterprise-…), and when the database URL carries it in a query parameter whose name is percent-encoded (?p%61ssword=…).

  • #2238 fc8c902 Thanks @nextly-agent! - The nextly.config.ts written when Nextly is added to an existing app keeps its placeholder comments where they apply: // Add your collections here directly above collections, and // Add your singles (globals) here directly above singles, inside the exported config. Both used to run together on one line above export default.

  • #2225 224e51a Thanks @nextly-agent! - Adding Nextly to an existing app no longer writes through a folder on a file's path that is a symbolic link. When the app's app/admin, types/generated or another folder Nextly writes into is a link to a directory elsewhere, nothing is written there, which could have been outside the project. The file is reported as left alone, with the folder that is a link and, for the admin's files, the content Nextly needs, as the media routes already did.

  • #2151 7dfb74b Thanks @nextly-agent! - Adding Nextly to an existing Next.js app now writes the admin's media route under /admin, the same file a new project gets, so uploads, edits and deletes in the admin's media library work. Every request to it is checked against the signed-in caller's media permissions; the public media route stays read-only. The routes import the Nextly config by a path relative to themselves, so no import alias is needed. A media or health route the app already has is left exactly as it was and reported, and for the admin's route the report includes the content to add. A route is not written where a Pages Router page answers its URL (in pages/ or src/pages/), since Next.js refuses to build a page and an app route at one URL; that is reported in the same way.

  • #2216 b766e82 Thanks @nextly-agent! - Adding Nextly to an existing app no longer replaces or refuses over files the app already has, and the run ends with one report of everything it did. Before, a nextly.config.ts, admin API route or admin page already there stopped the whole run with an error, and the admin's layout.tsx was written over whatever the app had.

    Now every file is created only where nothing is (exclusively, so a link or a file that appears meanwhile is never replaced). One already there is left byte for byte, and the report says so, with why and, for the admin's routes, page and layout, the content Nextly needs there. The report lists each file written, each extended with what was added (the Next config's packages included), and each left alone, across every step, the env files and their warnings included, in one place after the spinner, by path, key and package name only: no env value, the generated secret included, is ever printed.

  • #2019 8561690 Thanks @nextly-agent! - A role given with an expiry date now stops granting anything once that date
    passes. Until now the expiry was ignored almost everywhere: an expired role
    kept its permissions in every check, an expired super-admin assignment kept
    super-admin authority, and login and session refresh still put the role in
    the session's claims. Cached permission answers, super-admin answers and API
    key grants now also end no later than the expiry of a role they came from, and
    a session's access token ends no later than the first of its roles to lapse, so
    the client refreshes into claims without it.

    Assigning a role whose earlier assignment has expired now gives it again,
    instead of answering that it is already assigned. Listing a user's roles shows
    only the roles in force. If a user's roles cannot be read at login or refresh,
    the session is refused rather than issued with no roles.

    Permission answers cached in the database before this release are no longer
    read: they could have come from a role that has since expired. They expire and
    are cleared as usual, and the first check of each permission after the upgrade
    is worked out afresh.

    Breaking, for code that builds its own AuthRouterDeps for
    routeAuthRequest (nextly/auth, experimental): the fetchRoleIds(userId)
    dependency is replaced by fetchSessionRoles(userId), which returns
    { roleIds, validUntil } (the SessionRoles type, now exported):
    the role ids in force, and the soonest expiry among them in epoch milliseconds,
    or undefined when none expires. Read only assignments whose expires_at is
    empty or later than now. The access token is bounded by validUntil.

  • #2105 fdff3a8 Thanks @nextly-agent! - Moving a block into a component instance with Alt+ArrowRight no longer makes it
    vanish. A component instance is not a registered block, so the keyboard move
    could not read its slots and filed the block under the engine's default slot,
    which an instance draws only when its component exposes it. The move now reads
    the slots the instance's component exposes and places the block in one of them,
    and offers no move when the component exposes several and none is the default.

  • #2095 d69c112 Thanks @nextly-agent! - Moving a block into the container above it with Alt+ArrowRight no longer makes
    it vanish. The keyboard move filed the block under a slot name of its own that
    no core container renders, so the block stayed in the page's data, selected in
    the inspector and listed in the breadcrumb, while the canvas showed nothing.
    It now lands in the default slot every core container declares and renders.

    Each row of the Layers panel also names the block it stands for in a
    data-nx-layer attribute, as the canvas already does with data-nx-node. Row
    labels repeat (every paragraph is "Text"), so a test or an integration had no
    stable way to find one block's row.

  • #2203 59a1e9a Thanks @nextly-agent! - A job written inline in the config's jobs list or a plugin's contributes.jobs, as an object rather than through defineJob, keeps JobInput as the type of its handler's input, alongside jobs defined with a typed input, which fit those lists without a cast.

  • #2264 43f48ed Thanks @nextly-agent! - Back and Forward with unsaved changes now ask before leaving in a real browser. In Chromium the editor's guard heard the history move only after the app's router had already drawn the next page, so pressing Back on a dashboard in customise mode, an entry or a single with unsaved changes left without asking and lost the changes. The guard now listens from the moment the admin loads, ahead of any router.

  • #2156 d03cde4 Thanks @nextly-agent! - In a new blog project, clearing the search box and typing a new query no longer shows the old query's results while the new search loads, and "No results" waits until the search has answered.

  • #2030 523adc3 Thanks @nextly-agent! - In development, a boot that cannot apply its schema, and the check that every
    declared relation resolves before plugins initialise, now log the error with
    its code, its cause chain and its log context, through the same description
    every other operator log uses. Before, they printed the message alone, so a
    refused relation's report left out the code and the relations it named.

  • #2017 774d4c5 Thanks @nextly-agent! - A Schema Builder relationship's foreign key works for a camelCase field, a long
    name, and a relationship an older save stored.

    On SQLite, a relationship named in camelCase (leadAuthor, stored in
    lead_author) gave the table drizzle-kit rebuilds a key over a missing
    column. A long collection and field name made a key name MySQL refuses past
    64 characters; it is now shortened with a hash past 63, as other derived names
    are. And a required relationship that nulls on delete, stored by an older
    save, no longer refuses every later save of its collection.

  • #1991 285b88b Thanks @nextly-agent! - Admin: a schema-builder page is no longer shown before the server has said the builder is on. Opening a builder address where the builder is off now goes to the dashboard without the editor appearing first, and a failed request shows a message instead of the builder.

  • #2033 436b3bd Thanks @nextly-agent! - A Schema Builder one-to-one relationship stays unique when a save changes its
    table, and a SQLite save no longer drops a table's triggers.

    Adding a relationship to a Builder collection, or changing a field's type on
    SQLite, rebuilt or altered the table in a way that dropped the UNIQUE the
    Builder gives a one-to-one relationship's column, on SQLite and PostgreSQL, so
    the column then accepted duplicates. While a table held such a column, a
    SQLite save of another collection could also be refused. Both now keep the
    constraint. A SQLite save that would rebuild a table with triggers on it is
    refused, naming the table, instead of dropping the triggers; make that change
    with a migration that recreates them.

  • #2014 6e12fe5 Thanks @nextly-agent! - A Schema Builder collection keeps its foreign keys, and gets the key of a
    relationship added to it.

    The Builder creates a collection's table with each relationship's key, but
    every later save went through the schema push, which described no keys. So a
    relationship added to an existing collection got no key on any database. On
    SQLite, where a key lives only in the table's definition, a save that changed
    a field's type rebuilt the table without its key, and a save that rebuilt a
    collection other collections point at failed outright. A save now adds the key
    of a relationship it adds, and keeps every key a table already holds through a
    rebuild, with no delete action running during it. A relationship that was
    added before this and has no key is left as it is.

  • #2040 4e27dce Thanks @nextly-agent! - A repeater or group on a table the Schema Builder creates is now stored in the
    database's JSON type (jsonb on PostgreSQL, json on MySQL), as on every
    code-first table. That covers Builder collections and singles, and the singles
    nextly db:sync creates itself. They were created as text, while the schema
    db:sync compares against names the JSON type, so each such table was offered
    a conversion its author never asked for. Tables created from now on match.

    Existing tables keep their text columns: this changes no stored data, and
    db:sync still offers to convert them. That conversion succeeds when every
    stored value is valid JSON. It refuses the whole apply when one is not (an
    empty string, for example), leaving the column as it was. SQLite is unchanged:
    it stores JSON as text either way.

  • #2051 7076ae4 Thanks @nextly-agent! - New command: nextly users:clear-plaintext-passwords. Earlier releases stored some
    passwords as typed instead of hashing them (32 characters or more containing a colon,
    given when an account was created, or any value sent to PATCH /api/users/:id/password).
    Such a value never let anyone sign in, but it is readable in the database and its backups.

    Run the command once the upgraded version serves every instance, since an old instance
    can still store a password as typed. Without flags it only counts the affected accounts;
    with --apply --confirm-default-hasher it clears each stored value and ends that account's
    sessions, printing only a count: none can be renewed, and an access token already issued
    lasts until it expires, within fifteen minutes by default. --apply alone changes nothing: the second flag is your
    statement that no stored password was written by a custom passwordHasher, now or earlier,
    since the command cannot see one and would take its hashes for plain text. Values in a format other
    hashers write (an argon2, scrypt or pbkdf2 string in the modular-crypt form their libraries
    write, or a hex salt and digest joined by a colon) are never counted or cleared; a value that
    only starts with $ is. nextly migrate now prints the same count and names the command, and changes
    nothing itself. People whose password was cleared are refused at sign-in exactly as
    before, and set a new password with Forgot password, or an administrator sets one.

  • #2096 12d1c70 Thanks @nextly-agent! - nextly/config no longer pulls node:crypto into a browser bundle. It
    published three dashboard layout limits from a module that also builds a hash
    with node:crypto; the limits now come from a module with no imports, and keep
    their values.

    Every entry listed as safe for a browser is now checked: a test walks each
    one's runtime imports and fails, naming the entry and the path, if any reaches
    a Node built-in, a database package or a module marked with server-only. The
    database-operator re-exports, the table DSL and the stored-bytes helper reach
    server-only code by design and are imported only from server code, so they are
    listed as server entries; this changes no build output.

  • #2029 918f7f0 Thanks @nextly-agent! - A collection's db options are now checked where the collection is defined.
    Before, an idType the write path did not recognise gave UUIDv4 ids in
    silence, and an allowIdOnCreate that was not true discarded every id a
    caller supplied, and any other name in db was not read at all. Now an
    idType other than uuid or uuidv7, an allowIdOnCreate that is not a
    boolean, a name that is not an option (a misspelling such as idTyp), and
    db options that are not an object are refused, naming the option.

    serial, which a config carried over from Payload may hold, is refused with
    its own message: a collection's id is text, which every relation and API path
    reads. A sequential number, such as an order number, goes on a table keyed by
    col.serial().

  • #2064 92317b6 Thanks @nextly-agent! - @nextlyhq/plugin-commerce's checkout, still @experimental and unpublished,
    refuses a line past what its item now allows and an order whose jobs went
    stale while the catalogue was read, retries a payment's start only with the
    provider it began with, holds catalogue names and SKUs to 255 characters, and
    keeps provider references that differ only in case apart on every database.

  • #2052 1b957e9 Thanks @nextly-agent! - @nextlyhq/plugin-commerce gains the plugin itself, commerce(), still
    @experimental and unpublished: its own tables and migrations, a built-in
    products collection to sell from or a catalogue source of the site's own, and a
    cart kept on the server behind an HTTP-only cookie, with REST routes and typed
    server functions for a storefront. Catalogue sources can report items sold to
    order, which hold no stock and are capped per line.

  • #2056 8c6956f Thanks @nextly-agent! - @nextlyhq/plugin-commerce gains checkout, still @experimental and
    unpublished: a cart is quoted from the catalogue's own prices and completed into
    one numbered order, with its stock held while it is unpaid and its payment
    started through the store's payment provider, never twice, even when a request
    is repeated or the provider is down. Checkout takes no orders until commerce's
    jobs have run, which is a launch step. Stock and order records keep catalogue
    references apart exactly, letter case included, on every database.

  • #2249 e1e391b Thanks @nextly-agent! - A comparison can start from the version you last looked at. A client marks
    a version as seen with PUT …/versions/{n}/seen (people only); the admin
    doesn't send it yet. API keys and the system are refused, and no read
    records anything. Marks count by visit: a mark more than 30 minutes after a
    marker's last one starts a new visit, and "last looked at" is the furthest
    version of the previous visit, kept per person, document and language. The
    last-viewed baseline compares from it, falling back to the nearest older
    version when retention removed it and naming the one removed, and the
    history list marks the row where you last looked. Markers live in a new core
    table, nextly_version_views, which nextly migrate creates, and go with
    their document, collection, Single or person.

  • #2125 e356b75 Thanks @nextly-agent! - A version comparison now names fields the way the editor does. Each field
    carries the label it declares (declaredLabel, or null when it declares
    none, so a reader applies its own naming rule instead of showing the field's
    key) and its place among containers (path), including groups with no name,
    whose heading was lost before. A field the reader may not read is reported in
    its place as hidden, with nothing of its values, instead of disappearing.
    Keys with no field are marked system (a framework column such as when the
    document was first published) or removed, and removed fields are listed
    last. A comparison with withheld fields is marked incomplete, so "no
    changes" then means only that nothing the reader may read changed; a
    changes-only comparison never lists a hidden field. The new response fields
    are experimental.

    Compatibility: FieldDiff gains the hidden member. Code that switches over
    kind exhaustively needs a hidden case (or a default) after upgrading; the
    admin draws it as "Hidden: you don't have access to this field".

  • #2148 4358d85 Thanks @nextly-agent! - A version comparison can start from a baseline the server works out, instead
    of two version numbers: ?to=6&baseline=previous compares version 6 with the
    version before it in the same language, published with that language's
    latest published version, and draft with its current draft. With neither a
    from nor a baseline, a comparison opens on the current draft against what
    is published when both exist, and otherwise on a version against the one
    before it. A language's first version is refused with NO_BASELINE, and the
    response names the baseline it used. Core features can register their own
    baselines. Comparing two named versions works as before. Baselines are
    experimental.

  • #2128 2011f67 Thanks @aqib-rx! - A many-to-many relation inside a component was stored as a column on the component's row, but the schema planner did not expect that column. A push over such a component could plan to drop the column that stores the link. The planner now expects the same column the component table is created with, so a push, migration, or introspection finds no difference.

  • #2094 b24afb3 Thanks @nextly-agent! - A page builder document is now judged as a reader receives it, on every write
    path — the editor, the API, a batch, and an import that skips hooks:

    • a component save that would make the component contain itself is refused,
      naming the chain;
    • a document whose components put a block where the nesting rules forbid, or
      nest deeper than the depth limit counted from the top of the document, is
      refused, naming the placement;
    • a component change that would break a page, Layout or component placing it
      is refused, saying how many documents it would break and which rule. The
      documents themselves are named only in the server log, since they were found
      and read as the system. Documents the usage index cannot vouch for are
      reported in the server log as unjudged.

    Plugin field types gain an experimental write argument on validate: which
    entry the write is for, and readStored, a read of stored values of the
    validator's own type on the write's own connection — so a batch's own rows are
    seen. The page builder's former cycle-guard beforeChange hook is removed;
    bulk component writes with hooks running are no longer refused for being in a
    transaction.

  • #2263 e4bab4c Thanks @nextly-agent! - Under next dev, a saved config that imports a file in a folder that does not exist yet now applies once that folder and file are created. Before, the import was seen only on the next save of the config.

Notes truncated to stay within GitHub's release body limit. The complete changelog for every package is in its CHANGELOG.md at v0.0.2-alpha.67.

Packages

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