Skip to content

v0.0.2-alpha.44

Pre-release
Pre-release

Choose a tag to compare

@mobeenabdullah mobeenabdullah released this 28 Jul 21:23
1140c6a

Released 17 packages at 0.0.2-alpha.44 in lockstep. Every package below ships at this version.

What's changed

Patch Changes

  • #374 a44ab69 Thanks @mobeenabdullah! - Component tables are always derived from the component slug, resolved through a single canonical path. A custom dbName is no longer accepted on defineComponent or components.create(): it could name storage the component does not own, and whether two spellings refer to one table depends on database server configuration rather than anything the config can state. Components that relied on it should drop the option and let the table name derive from the slug.

  • #380 90108db Thanks @mobeenabdullah! - Relationships nested one level deeper now expand for collections you defined in code, not only for those created in the Schema Builder.

    ?depth=2 promises to populate a related document's own relationships, and it did so only for Builder-created collections. Resolving a target collection's fields read one of the two shapes those collections are stored in, so a code-first target resolved to nothing, the recursion guard failed, and the second hop was skipped silently at any depth — you got a bare id where a document was promised.

    Two consequences, both now closed. A depth: 2 read returns what it says it returns. And an access rule reading across two hops — data.author?.organization?.suspended !== true — was enforced on a Builder collection while being quietly unenforced on a code-first one, so the same rule over the same data gave different answers depending on how the collection happened to be defined.

    Worth knowing if you use code-first collections with chained relationships: reads at depth 2 or more will now issue the queries that second hop requires, where previously they stopped early. Depth still bounds the walk, and a field's own maxDepth still overrides it.

  • #379 655532d Thanks @mobeenabdullah! - Plugin-contributed field types can now validate what they store. PluginFieldType.validate(value, { data, req, field, path, mode }) returns true, a message, or a list of issues with their own paths. Previously a custom type could be invented but say nothing about what belonged in it.

    Values of a custom type are now also checked against the storage primitive the type declares. A number-backed type used to accept the string "3" on its way to a numeric column, because the built-in rules only ever matched built-in type names; they now run first, then the type's validate, then the field's own. A disabled plugin's field types keep their schema but no longer run their validate, matching how every other plugin behavior is skipped.

    json fields now reject a value JSON cannot represent — a cycle, a BigInt, a bare function — as a validation error naming the field, instead of letting it reach the driver and fail there as a server error. Values JSON merely reshapes, such as an undefined member, are still accepted. contributes.fieldTypes is documented for the first time.

  • #367 66053c3 Thanks @mobeenabdullah! - Honour a Single's declared hooks and field defaults

    Two documented parts of the defineSingle() config silently did nothing:

    • Hookshooks: { beforeRead, afterRead, beforeChange, afterChange } were
      never registered, so none of them ran. They now register (via the scaffolded
      init helper, alongside collection hooks) and execute on the single read and
      update paths. beforeRead remains side-effect-only, matching collections.
    • Field defaults — a defaultValue on a Single's field never applied; the
      first read auto-created the row with null in every defaulted column, because
      a function defaultValue cannot survive serialization to dynamic_singles.
      Defaults are now resolved from the live code-first config, so a scalar or
      structured (group/repeater) default lands on the auto-created document.
  • #366 ee20d18 Thanks @mobeenabdullah! - Singles now get their storage table on MySQL, and on any app configured with only a DATABASE_URL rather than an explicit DB_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 declared slug field was emitted as a type MySQL cannot put a unique index on, so the table was never created and the first read reported it missing.

    The plugin test harness can also boot against a real database: createTestNextly({ dialect: "postgresql" | "mysql" }) creates a dedicated database for that instance and drops it on destroy(), and getConfiguredTestDialects() reports which dialects the environment is configured for so a suite can cover those and skip the rest. The default is unchanged: in-memory SQLite.

  • #381 22b43f2 Thanks @mobeenabdullah! - Updating a Single no longer returns related fields the writer is not allowed to read.

    The response expands relationships, and those rows belong to another collection carrying its own field-level access.read rules. Read paths have evaluated them since the field-access work landed; this path forwarded no caller, so it returned every related field intact — including ones the same caller's GET would withhold. That made the write path a way around the rule: write anything, read the response back.

    A writer supplied a relationship id, not the related row's protected fields, so "they supplied the data" does not cover them. The rule that applies is the target collection's own, and it is now evaluated against the caller that made the write.

    This reaches every hop the response expands, not only the first: a related row's own relationships carry the rules of the collection at the far end, and those are evaluated too.

    Every caller the access gate applies to is judged, including one with no identity — an anonymous write permitted by a public update rule gets the same answer its read would give. Only a trusted write bypasses this, through overrideAccess rather than through an absent user.

    One consequence worth knowing if you write afterUpdate hooks: they receive the response as the caller will see it, so a related field that caller may not read is already gone. That matches how reads behave — related-row rules are applied while relationships expand, before afterRead hooks run — and it is why the two paths now agree. The Single's own fields are still redacted after your hooks, unchanged.

  • #369 f822937 Thanks @mobeenabdullah! - A read that cannot assemble the evidence its access rule needs is now refused rather than allowed. This covers relationships nested in a group or repeater, and counts references as well as checking them: a hasMany expansion 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 configured maxDepth: 0 is left alone, since an unexpanded reference is what that asks for, and so is one declared with the legacy relation type, which is never populated at all. Localized references are checked too, by recording what the document referred to once translations were overlaid and before anything was expanded — which is the only point a localized reference is visible at all, and makes the count of what came back comparable for those fields as well. Upload references are held to the same bar as relationships. A relationship pointing at several collections is left alone: it is stored and served as a reference rather than populated, so demanding a document there would refuse every read of a Single that has one.

    Translation loading fails the read rather than reading through it. A companion query that errored was previously swallowed, leaving the main row's value in place — which a rule cannot tell apart from a translation that says so.

    A relationship that exists only inside a group or repeater is now expanded on read. The check for whether a Single had any relationships to expand looked at top-level fields only, so a schema that nests all of them was returned with bare ids. Reaching into containers is opt-in, and the write-response path does not opt in: it threads no caller, so the target collection's field rules cannot be evaluated for it and the rows it pulled in could not be redacted. Expansion is best-effort by design — a related table that cannot be read yields the bare id — which is right for a response and wrong for a document about to be judged: a rule written as data.author?.suspended !== true reads the missing row as permission. Every stored reference a rule may inspect is checked to have become a row before the rule is asked, and a read whose evidence is incomplete fails instead.

    A Single deleted while it is being read no longer materializes defaults nobody authorized. The rule approved the stored row; if that row disappears before the read fetches it again, what would be created is a default document no rule has seen. It is judged before it is written, rather than persisted — with its localized defaults and first version — and refused afterwards.

    The depth an access rule sees no longer drops below an ordinary read's. A caller asking for depth: 0 narrows their response, and the authorization view now expands at least as far as an unqualified read would, and further when the caller asked for more.

    Access callbacks can no longer write through a Map or Set in their argument, including through an object used as a Map key. They already received plain objects and arrays as copies; these were passed by reference, so a callback could change the payload it was only asked to judge. Data that refers to itself is copied without recursing forever, and a value reachable by two paths stays one object in the copy.

    A ?depth=0 read still gets the references it asked for. The response deliberately leaves relationships unexpanded at that depth, so holding it to "every reference became a document" would refuse exactly what was requested; the authorization view judges those relationships at the full read depth regardless. Uploads are unaffected, since they populate at any depth.

    The decision made on the document you actually receive is held to the same completeness bar as the earlier one, so expansion that succeeds before your hooks run and fails after cannot leave a rule deciding on a reference where it expects a document. That check runs on the assembled document, before your afterRead hooks shape it — a hook is free to drop or replace a relationship, and nothing tells that apart from an expansion that failed.

    A read refused for incomplete evidence reports the canonical internal error rather than the underlying failure's own message, which for a database fault is schema detail. That covers relationship expansion, component population, translation loading and the per-locale overview alike.

    A group or repeater whose stored value cannot be read — malformed JSON, valid JSON of the wrong shape such as a list where the field declares a group, or a repeater row that is not a row — now fails the read rather than being treated as empty, which would have walked past every relationship inside it.

    Translation loading and the per-locale overview both fail the read when it is being judged, rather than leaving the fields off. An ordinary read is still served best-effort; a rule cannot tell "no translations" from "the query failed", so a read about to be judged gets the failure instead.

    Metadata attached to a Map or Set under a symbol key survives the copy handed to an access callback. Arrays keep their holes and their own properties when handed to an access callback, under the keys they actually have — a decoration like "01" no longer overwrites element 1. Sparse arrays keep their holes, and a Map or Set carrying its own properties keeps them, so a rule reading either decides on the structure the payload actually has.

    A subclass of Map or Set reaches an access callback as itself rather than rebuilt as the base collection, which would have discarded its methods and private state.

  • #375 0febd62 Thanks @mobeenabdullah! - Collections and Singles created in the Schema Builder can now opt out of webhook recording from their Advanced tab, so content holding personal data never reaches the outbox or any subscribed endpoint. The setting is stored on the entity, takes effect on the next write, and survives restarts. Existing installs should run nextly migrate to add the new registry column; until then the switch has no effect and recording continues as before.

  • #378 0498e02 Thanks @mobeenabdullah! - feat(nextly): capture versions on programmatic entry writes

    The tx-API and batch entry writes (createEntryInTransaction, updateEntryInTransaction, and the createEntries/updateEntries batch internals) now record a durable version snapshot and carry the full relational document (component subtrees and many-to-many relations) on their outbox event, matching the interactive create/update paths. Programmatic writers (importers, plugins, agents) previously left no version history and emitted parent-columns-only events.

  • #377 3785345 Thanks @mobeenabdullah! - Programmatic entry writes now emit webhook events. Writes through the transaction API (createEntryInTransaction/updateEntryInTransaction), the batch helpers (createEntries/updateEntries), and publishAllLocales previously recorded no webhook events, so importers, agents, and plugins writing through them were invisible to webhook subscribers. These paths now record entry.created/entry.updated and the corresponding published/unpublished/status_changed lifecycle 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/ui
  • create-nextly-app
  • nextly