v0.0.2-alpha.44
Pre-releaseReleased 17 packages at 0.0.2-alpha.44 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
-
#374
a44ab69Thanks @mobeenabdullah! - Component tables are always derived from the component slug, resolved through a single canonical path. A customdbNameis no longer accepted ondefineComponentorcomponents.create(): it could name storage the component does not own, and whether two spellings refer to one table depends on database server configuration rather than anything the config can state. Components that relied on it should drop the option and let the table name derive from the slug. -
#380
90108dbThanks @mobeenabdullah! - Relationships nested one level deeper now expand for collections you defined in code, not only for those created in the Schema Builder.?depth=2promises to populate a related document's own relationships, and it did so only for Builder-created collections. Resolving a target collection's fields read one of the two shapes those collections are stored in, so a code-first target resolved to nothing, the recursion guard failed, and the second hop was skipped silently at any depth — you got a bare id where a document was promised.Two consequences, both now closed. A
depth: 2read returns what it says it returns. And an access rule reading across two hops —data.author?.organization?.suspended !== true— was enforced on a Builder collection while being quietly unenforced on a code-first one, so the same rule over the same data gave different answers depending on how the collection happened to be defined.Worth knowing if you use code-first collections with chained relationships: reads at depth 2 or more will now issue the queries that second hop requires, where previously they stopped early. Depth still bounds the walk, and a field's own
maxDepthstill overrides it. -
#379
655532dThanks @mobeenabdullah! - Plugin-contributed field types can now validate what they store.PluginFieldType.validate(value, { data, req, field, path, mode })returnstrue, a message, or a list of issues with their own paths. Previously a custom type could be invented but say nothing about what belonged in it.Values of a custom type are now also checked against the storage primitive the type declares. A
number-backed type used to accept the string"3"on its way to a numeric column, because the built-in rules only ever matched built-in type names; they now run first, then the type'svalidate, then the field's own. A disabled plugin's field types keep their schema but no longer run theirvalidate, matching how every other plugin behavior is skipped.jsonfields now reject a value JSON cannot represent — a cycle, aBigInt, a bare function — as a validation error naming the field, instead of letting it reach the driver and fail there as a server error. Values JSON merely reshapes, such as anundefinedmember, are still accepted.contributes.fieldTypesis documented for the first time. -
#367
66053c3Thanks @mobeenabdullah! - Honour a Single's declared hooks and field defaultsTwo documented parts of the
defineSingle()config silently did nothing:- Hooks —
hooks: { beforeRead, afterRead, beforeChange, afterChange }were
never registered, so none of them ran. They now register (via the scaffolded
init helper, alongside collection hooks) and execute on the single read and
update paths.beforeReadremains side-effect-only, matching collections. - Field defaults — a
defaultValueon a Single's field never applied; the
first read auto-created the row withnullin every defaulted column, because
a functiondefaultValuecannot survive serialization todynamic_singles.
Defaults are now resolved from the live code-first config, so a scalar or
structured (group/repeater) default lands on the auto-created document.
- Hooks —
-
#366
ee20d18Thanks @mobeenabdullah! - Singles now get their storage table on MySQL, and on any app configured with only aDATABASE_URLrather than an explicitDB_DIALECT. The DDL for a Single's table was generated from an optional environment variable that defaults to PostgreSQL instead of from the database the statements were about to run against, and a declaredslugfield was emitted as a type MySQL cannot put a unique index on, so the table was never created and the first read reported it missing.The plugin test harness can also boot against a real database:
createTestNextly({ dialect: "postgresql" | "mysql" })creates a dedicated database for that instance and drops it ondestroy(), andgetConfiguredTestDialects()reports which dialects the environment is configured for so a suite can cover those and skip the rest. The default is unchanged: in-memory SQLite. -
#381
22b43f2Thanks @mobeenabdullah! - Updating a Single no longer returns related fields the writer is not allowed to read.The response expands relationships, and those rows belong to another collection carrying its own field-level
access.readrules. Read paths have evaluated them since the field-access work landed; this path forwarded no caller, so it returned every related field intact — including ones the same caller'sGETwould withhold. That made the write path a way around the rule: write anything, read the response back.A writer supplied a relationship id, not the related row's protected fields, so "they supplied the data" does not cover them. The rule that applies is the target collection's own, and it is now evaluated against the caller that made the write.
This reaches every hop the response expands, not only the first: a related row's own relationships carry the rules of the collection at the far end, and those are evaluated too.
Every caller the access gate applies to is judged, including one with no identity — an anonymous write permitted by a public update rule gets the same answer its read would give. Only a trusted write bypasses this, through
overrideAccessrather than through an absent user.One consequence worth knowing if you write
afterUpdatehooks: they receive the response as the caller will see it, so a related field that caller may not read is already gone. That matches how reads behave — related-row rules are applied while relationships expand, beforeafterReadhooks run — and it is why the two paths now agree. The Single's own fields are still redacted after your hooks, unchanged. -
#369
f822937Thanks @mobeenabdullah! - A read that cannot assemble the evidence its access rule needs is now refused rather than allowed. This covers relationships nested in a group or repeater, and counts references as well as checking them: ahasManyexpansion drops the entries it could not fetch, so a list that came back shorter is evidence that went missing, not evidence that nothing is there. A relationship configuredmaxDepth: 0is left alone, since an unexpanded reference is what that asks for, and so is one declared with the legacyrelationtype, which is never populated at all. Localized references are checked too, by recording what the document referred to once translations were overlaid and before anything was expanded — which is the only point a localized reference is visible at all, and makes the count of what came back comparable for those fields as well. Upload references are held to the same bar as relationships. A relationship pointing at several collections is left alone: it is stored and served as a reference rather than populated, so demanding a document there would refuse every read of a Single that has one.Translation loading fails the read rather than reading through it. A companion query that errored was previously swallowed, leaving the main row's value in place — which a rule cannot tell apart from a translation that says so.
A relationship that exists only inside a group or repeater is now expanded on read. The check for whether a Single had any relationships to expand looked at top-level fields only, so a schema that nests all of them was returned with bare ids. Reaching into containers is opt-in, and the write-response path does not opt in: it threads no caller, so the target collection's field rules cannot be evaluated for it and the rows it pulled in could not be redacted. Expansion is best-effort by design — a related table that cannot be read yields the bare id — which is right for a response and wrong for a document about to be judged: a rule written as
data.author?.suspended !== truereads the missing row as permission. Every stored reference a rule may inspect is checked to have become a row before the rule is asked, and a read whose evidence is incomplete fails instead.A Single deleted while it is being read no longer materializes defaults nobody authorized. The rule approved the stored row; if that row disappears before the read fetches it again, what would be created is a default document no rule has seen. It is judged before it is written, rather than persisted — with its localized defaults and first version — and refused afterwards.
The depth an access rule sees no longer drops below an ordinary read's. A caller asking for
depth: 0narrows their response, and the authorization view now expands at least as far as an unqualified read would, and further when the caller asked for more.Access callbacks can no longer write through a
MaporSetin their argument, including through an object used as aMapkey. They already received plain objects and arrays as copies; these were passed by reference, so a callback could change the payload it was only asked to judge. Data that refers to itself is copied without recursing forever, and a value reachable by two paths stays one object in the copy.A
?depth=0read still gets the references it asked for. The response deliberately leaves relationships unexpanded at that depth, so holding it to "every reference became a document" would refuse exactly what was requested; the authorization view judges those relationships at the full read depth regardless. Uploads are unaffected, since they populate at any depth.The decision made on the document you actually receive is held to the same completeness bar as the earlier one, so expansion that succeeds before your hooks run and fails after cannot leave a rule deciding on a reference where it expects a document. That check runs on the assembled document, before your
afterReadhooks shape it — a hook is free to drop or replace a relationship, and nothing tells that apart from an expansion that failed.A read refused for incomplete evidence reports the canonical internal error rather than the underlying failure's own message, which for a database fault is schema detail. That covers relationship expansion, component population, translation loading and the per-locale overview alike.
A group or repeater whose stored value cannot be read — malformed JSON, valid JSON of the wrong shape such as a list where the field declares a group, or a repeater row that is not a row — now fails the read rather than being treated as empty, which would have walked past every relationship inside it.
Translation loading and the per-locale overview both fail the read when it is being judged, rather than leaving the fields off. An ordinary read is still served best-effort; a rule cannot tell "no translations" from "the query failed", so a read about to be judged gets the failure instead.
Metadata attached to a
MaporSetunder a symbol key survives the copy handed to an access callback. Arrays keep their holes and their own properties when handed to an access callback, under the keys they actually have — a decoration like"01"no longer overwrites element1. Sparse arrays keep their holes, and aMaporSetcarrying its own properties keeps them, so a rule reading either decides on the structure the payload actually has.A subclass of
MaporSetreaches an access callback as itself rather than rebuilt as the base collection, which would have discarded its methods and private state. -
#375
0febd62Thanks @mobeenabdullah! - Collections and Singles created in the Schema Builder can now opt out of webhook recording from their Advanced tab, so content holding personal data never reaches the outbox or any subscribed endpoint. The setting is stored on the entity, takes effect on the next write, and survives restarts. Existing installs should runnextly migrateto add the new registry column; until then the switch has no effect and recording continues as before. -
#378
0498e02Thanks @mobeenabdullah! - feat(nextly): capture versions on programmatic entry writesThe tx-API and batch entry writes (createEntryInTransaction, updateEntryInTransaction, and the createEntries/updateEntries batch internals) now record a durable version snapshot and carry the full relational document (component subtrees and many-to-many relations) on their outbox event, matching the interactive create/update paths. Programmatic writers (importers, plugins, agents) previously left no version history and emitted parent-columns-only events.
-
#377
3785345Thanks @mobeenabdullah! - Programmatic entry writes now emit webhook events. Writes through the transaction API (createEntryInTransaction/updateEntryInTransaction), the batch helpers (createEntries/updateEntries), andpublishAllLocalespreviously recorded no webhook events, so importers, agents, and plugins writing through them were invisible to webhook subscribers. These paths now recordentry.created/entry.updatedand the correspondingpublished/unpublished/status_changedlifecycle events inside the write transaction, so an event is delivered for every entry write and is never emitted for a write that rolls back.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly