# 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 in   | No plugin required                                                                                                             |
| Admin      | A **Releases** section with a calendar view, and an "Add to release" action on documents                                       |
| Permission | Scheduling requires publish access. A key scoped only to publishing releases can schedule without holding a general read grant |

## What it is not

> [!WARNING]
> 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

| Step        | What happens                                                                |
| ----------- | --------------------------------------------------------------------------- |
| Create      | An empty release with a name                                                |
| Add members | Each member names a document and an action, `publish` or `unpublish`        |
| Schedule    | The release is given a time. Blocking problems are checked first            |
| Apply       | On a background-jobs pass after that time, every member's action is applied |
| Publish now | A draft or scheduled release is run at once instead of waiting for its time |
| Cancel      | An 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.

```ts
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

> [!NOTE]
> 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.

|            |                                             |
| ---------- | ------------------------------------------- |
| REST       | `POST /api/releases/:id/publish`            |
| Direct API | `nextly.releases.publishNow({ id })`        |
| Permission | The 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

> [!NOTE]
> 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:

| Tab         | Holds                                           | Order                        |
| ----------- | ----------------------------------------------- | ---------------------------- |
| `upcoming`  | Scheduled releases that are not overdue         | Soonest first                |
| `attention` | Stopped (`blocked`) releases, then overdue ones | Longest waiting first        |
| `drafts`    | Releases not yet scheduled                      | Newest first                 |
| `done`      | Published and cancelled releases                | Most 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:

| Parameter                           | Meaning                                                                    |
| ----------------------------------- | -------------------------------------------------------------------------- |
| `tab`                               | `upcoming`, `attention`, `drafts` or `done`. Not combined with `state`     |
| `reason`                            | With `tab=attention`: `stopped` or `overdue`, to read one part of that tab |
| `state`                             | One lifecycle state, when you need it rather than a tab                    |
| `kind`                              | `release` to leave out single-document releases, or `single` for only them |
| `search`                            | Text in the title or description, up to 200 characters                     |
| `order`                             | `soonest` or `latest`. Only with no tab or with `upcoming`                 |
| `page`                              | The page, from 1                                                           |
| `limit`                             | Rows per page, 50 by default and at most 200                               |
| `scheduledAfter`, `scheduledBefore` | A 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.

```json
{
  "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:

```ts
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](https://nextlyhq.com/docs/jobs.md) pass, which happens only when something
calls the jobs route.

[Running the queue](https://nextlyhq.com/docs/jobs.md#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.

> [!WARNING]
> **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.

> [!WARNING]
> 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](https://nextlyhq.com/docs/jobs.md#running-the-queue).

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.

> [!WARNING]
> 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](https://nextlyhq.com/docs/localization.md) — but that is publishing, not
> scheduling.

## Related

* [Localization](https://nextlyhq.com/docs/localization.md) — per-language publishing
* [Draft and published status](https://nextlyhq.com/docs/guides/draft-published-status.md)
* [Versions](https://nextlyhq.com/docs/versions.md) — what a publish changed
