You're reading docs for Nextly Alpha. APIs may change between releases.

Content Releases

Content Releases

Group documents into a release and publish or unpublish them together, at a time you choose.

A release is a set of documents that go live at the same instant. A product launch touching a landing page, three blog posts and a pricing single becomes one thing you schedule, rather than five things someone has to publish in the right order at 9am.

Each member of a release is set to either publish or unpublish, so the same release can put new pages live and retire old ones in the same moment.

Availability

Built inNo plugin required
AdminA Releases section with a calendar view, and an "Add to release" action on documents
PermissionScheduling requires publish access. A key scoped only to publishing releases can schedule without holding a general read grant

What it is not

This is scheduled publishing, not an approval workflow. There are no review stages, transitions or assignees. If you need "editor submits, manager approves, then it goes live", that is not what a release does.

The shape of a release

StepWhat happens
CreateAn empty release with a name
Add membersEach member names a document and an action, publish or unpublish
ScheduleThe release is given a time. Blocking problems are checked first
ApplyOn a background-jobs pass after that time, every member's action is applied
Publish nowA draft or scheduled release is run at once instead of waiting for its time
CancelAn unapplied release can be cancelled

A document is checked when it is added, the same way over REST and from the Direct API. It must exist, and the person adding it must be allowed to read it. Its collection or Single must have drafts, since a release moves documents between draft and published. A Single is recorded by its own stored row, whatever id the caller passed. A trusted Direct API call (overrideAccess, the default) skips only the read permission. The document still has to exist and have drafts.

Single-document releases

A release has a kind, set when it is made and never changed:

  • release, the default, holds any number of documents;
  • single is one document scheduled on its own, and holds exactly that one.
const release = await nextly.releases.create({
  title: "Launch post",
  kind: "single",
});

The database holds a single-document release to one document, so the rule holds even when two requests add documents to it at once. Adding a second document is refused with a conflict that says to put several documents in an ordinary release instead. A single-document release can't be scheduled before its document is added. Once it's scheduled, its document can't be taken out: cancel the release instead. Releases made before kinds existed read as release; nextly migrate adds the column without rewriting them. The kind option is experimental.

Publish now

Publish now is experimental: its name and answer may change before releases are finished.

Publish now runs a draft or scheduled release straight away. It makes the same checks as scheduling, so a release that would stop part-way is refused before any document is written. Then it moves the release to the current time, unless its time has already come, and starts the job runner's releases run at once, the same run a scheduled time gets: every document is written as the person who added it, and the release goes out together.

RESTPOST /api/releases/:id/publish
Direct APInextly.releases.publishNow({ id })
PermissionThe same as scheduling: publishing releases

The answer is the release as it then stands. Usually that is published, or blocked if a document stopped it part-way. It is still scheduled when a very large release outlasts the request, when a releases run was already in progress, or when the run could not be started: the release is then due, and goes out with that run or the job runner's next pass.

Like any releases run, it sends out every release whose time has come, not only this one, so a release that was already overdue goes out with it.

Releases that ran are final

A published release is the record of what went live, and a cancelled one the record of a launch called off. Neither changes again: no documents are added or removed, it is not scheduled again, and Publish now refuses it. To try again, create a new release.

A stopped release has not run. Remove or fix the documents that stopped it, then schedule it again. Publish now refuses it until then, so the fix is checked before anything goes out.

Listing releases

Tabs, search and paging are experimental: their names and shapes may change before the releases list is finished.

The list is paged on the server, so its total and every page cover every release on the site, however many there are. It is read in one of four tabs, and each release is in exactly one of them:

TabHoldsOrder
upcomingScheduled releases that are not overdueSoonest first
attentionStopped (blocked) releases, then overdue onesLongest waiting first
draftsReleases not yet scheduledNewest first
donePublished and cancelled releasesMost recently finished first

A release is overdue when it is still scheduled more than 10 minutes after its time. A release a few minutes past its time is still upcoming: the job runner applies it on its next pass. One still waiting after 10 minutes is one the runner is not reaching, usually because no scheduler calls it (see below).

Search matches text in the title or the description, ignoring case. It reads every release in the tab it searches, since no index can serve a match in the middle of a text; that is fast for the hundreds of releases a site keeps, and grows with the size of the history. The done tab is ordered by when each release finished, which no index serves either, so it too sorts every finished release it matches before taking a page. What you type is matched as written, so 50% finds "50% off" and not "50 years". On SQLite, case is ignored for the letters A to Z only. On MySQL, search follows the collation of the releases table: the default utf8mb4 collations ignore case, a case-sensitive one does not.

Ties are broken by id, so a release appears on exactly one page even when several share a time. Releases scheduled with no tab and no order come soonest first; other listings without a tab come latest first.

Over REST

GET /api/releases takes these query parameters:

ParameterMeaning
tabupcoming, attention, drafts or done. Not combined with state
reasonWith tab=attention: stopped or overdue, to read one part of that tab
stateOne lifecycle state, when you need it rather than a tab
kindrelease to leave out single-document releases, or single for only them
searchText in the title or description, up to 200 characters
ordersoonest or latest. Only with no tab or with upcoming
pageThe page, from 1
limitRows per page, 50 by default and at most 200
scheduledAfter, scheduledBeforeA window on the scheduled time, as ISO 8601 instants

The answer is the standard list envelope. meta.total is the number of releases matching, across all pages. The page and the total are read one after the other, as on every paged list. A release that changes tab between the two reads (a scheduled release the drain publishes, say) can leave the page and the total one apart for that one response. The next request reads both again.

{
  "items": [
    {
      "id": "…",
      "title": "Spring launch",
      "state": "scheduled",
      "can": {
        "schedule": true,
        "cancel": true,
        "addMember": true,
        "removeMember": true
      }
    }
  ],
  "meta": {
    "total": 42,
    "page": 1,
    "limit": 20,
    "totalPages": 3,
    "hasNext": true,
    "hasPrev": false
  }
}

To count a tab without reading its releases, ask for one row and read meta.total: GET /api/releases?tab=attention&limit=1.

From the Direct API

nextly.releases.find() takes the same filters and answers with the same { items, meta } envelope as every other Direct API list:

import { getNextly } from "nextly";

import config from "../../nextly.config";

const nextly = await getNextly({ config });

const { items, meta } = await nextly.releases.find({
  tab: "upcoming",
  search: "spring",
  page: 1,
  limit: 20,
});
// meta.total counts every matching release, across all pages

const needsAttention = await nextly.releases.count({ tab: "attention" });
const overdue = await nextly.releases.count({
  tab: "attention",
  reason: "overdue",
});

find() is paged like every list: without a limit it returns the first 50 releases. To read every release, ask for each page in turn, up to meta.totalPages (at most 200 per page).

count() takes the same tab, state, kind, window and search as find(), and gives the same number as its meta.total without reading the releases.

Filtering by containing (one document's releases) still works, and still comes soonest first, in the order the releases will apply to that document. tab, search and order are refused alongside it.

A release's documents (GET /api/releases/:id/members, nextly.releases.listMembers()) come back in the order they were added. Asking for the documents or the blocking reasons of a release that does not exist answers 404, not an empty list.

Scheduling stores a time; a job runner applies it

Scheduling records when a release is due. The work is done by a releases:drain sweep that runs as part of the background jobs pass, which happens only when something calls the jobs route.

Running the queue is where that is set up: the path, the shared secret the caller needs, and the two ways a scheduler ends up silently doing nothing. Wire it there once and every scheduled release depends on it.

With no scheduler configured, a release is never applied. Its due time passes and the sweep that would carry it out never runs. A scheduler configured but refused, by the wrong path or a missing secret, leaves you in the same place.

What that does not mean is that the content change is invisible. A read consults the same due-release rule the sweep does, so a scheduled draft can be revealed, and a takedown hidden, before anything has run. On a multi-instance deployment an instance other than the one that scheduled it can be up to 30 seconds behind, so treat this as "within seconds of the instant" rather than exactly at it.

As apply-due-releases.ts puts it, the read path can only make a due release LOOK applied. The sweep is what applies it for real, writing as the member's own author, and it is the only thing that advances the release's own state.

What a reader sees and what the stored rows hold are therefore two different questions while a release is waiting for the sweep, and the second one has more cases than the first: overlapping releases, a member whose author is gone, and a pass that applied some members before failing on another. If you integrate against the database directly rather than through Nextly, read apply-due-releases.ts before relying on release state.

A pass is bounded, which is why a due release is not always applied by the next tick: it claims at most 10 queued jobs and stops starting new ones after 20 seconds. The bounds and what they mean for a serverless timeout are on the jobs page.

The sweep can also defer work past its deadline while still reporting success. So a release is applied on the first pass that actually reaches and completes it, which is not always the first pass after its due time. With a backlog, or several expensive releases due together, expect it a pass or two later. Treat the scheduled time as "not before", not as "at".

Blocking reasons

Before a release can be scheduled, it is checked for problems that would make it fail half-way — a member pointing at a document that no longer exists, for example. Those are reported as blocking reasons so they can be fixed while there is still time, rather than surfacing as a partial publish at 9am.

An empty release has no blockers; there is nothing to go wrong.

One release covers every language

A member is the whole document. Adding a post to a release schedules that post in every language it has.

Per-locale scheduling is not supported: adding a member that names a single locale is refused, and the admin only offers the action from a document's default locale. Each language does have its own published state — see Localization — but that is publishing, not scheduling.