Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ NEXT_PUBLIC_API_URL=http://localhost:8080
# === CRON Secret for Internal API Calls ===
CRON_SECRET=your-secure-cron-secret-key

# === Content Preview Secret ===
# Signs the preview links that let a reviewer read an unpublished record
# without an account. The signature is the *only* access control on those
# links, so this is required whenever a content type has `editorial.preview`
# enabled: at least 32 random bytes, or the API refuses to boot in production
# and preview stays switched off everywhere else.
#
# openssl rand -base64 32
#
# Rotating this value revokes every outstanding preview link at once.
CONTENT_PREVIEW_SECRET=

# === AI (Vercel AI SDK) ===
# Gateway (default): one key for Anthropic, OpenAI, Google, etc. via `provider/model`
# id strings in `buildApiConfig({ ai: { models } })`.
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ NEXT_PUBLIC_WEB_URL=http://localhost:3000
# === CRON Secret for Internal API Calls ===
CRON_SECRET=your-secure-cron-secret-key

# === Content Preview Secret ===
# Signs the preview links that let a reviewer read an unpublished record
# without an account. The signature is the *only* access control on those
# links, so this is required whenever a content type has `editorial.preview`
# enabled: at least 32 random bytes, or the API refuses to boot in production
# and preview stays switched off everywhere else.
#
# openssl rand -base64 32
#
# Rotating this value revokes every outstanding preview link at once.
CONTENT_PREVIEW_SECRET=

# === Docker Database Postgres ===
POSTGRES_USER=root
POSTGRES_PASSWORD=root
Expand Down
77 changes: 77 additions & 0 deletions apps/docs/content/docs/dev/advanced/queue.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,86 @@ import { TypeTable } from "fumadocs-ui/components/type-table";
type: "Date",
default: "now",
},
pluginId: {
description:
"Who owns the handler, when that is not the plugin handling the request. The worker resolves handlers by `${pluginId}:${name}`, so dispatching a core task from a plugin route needs this - otherwise nothing ever claims the row.",
type: "string",
default: "the requesting plugin, or @vitnode/core",
},
tx: {
description:
"Join an existing transaction instead of using the request handle. Needed whenever the row the task refers to is written in the same unit of work, or the queue row can commit while that row rolls back.",
type: "Transaction",
},
}}
/>

<Callout type="info" title="Both exist because of scheduled publishing">
The [Content Engine](/docs/dev/content-engine/scheduling) books a schedule row
and its queue task in one transaction, from a plugin's route, against a task
core registers. That needs `tx` for atomicity and `pluginId` so the worker can
find the handler - and it is a shape any plugin can reuse.
</Callout>

## Tasks core ships

| Task | Attempts | What it does |
| --- | --- | --- |
| `send-email` | 3 | Delivers a rendered email through the configured provider |
| `rebuild-search-index` | 3 | Clears and rebuilds the [search](/docs/dev/content-engine/search) index, whole or per collection |
| `content-schedule` | 3 | Runs one [scheduled publication](/docs/dev/content-engine/scheduling). A cancelled, rescheduled or already-executed schedule is a no-op |
| `content-schedule-effects` | 5 | Emits the event, syncs search and expires the cache for a scheduled transition that has **already committed**. Never republishes |

`content-schedule` is worth reading as a pattern: its payload is
`{ scheduleId, generation }` and nothing else. Every real value is re-read from
the row under `FOR UPDATE`, so a task left over from a plan that has since
changed finds a mismatch and quietly does nothing - which is far more reliable
than trying to delete a queued row. It holds that lock from the claim all the
way to the commit, so a cancel arriving mid-flight waits and then honestly
reports that the schedule already ran.

### Why the effects are a second task

The pair is worth reading as a pattern too, because splitting them is the whole
point:

```text
content-schedule claim → publish → revision → settle → enqueue effects
── one transaction ──────────────────────────────────
content-schedule-effects event → search → cache bridge
```

A transition is a database write that either committed or did not. Announcing it
is three calls to systems a transaction cannot reach. Retrying them **together**
would re-run a publish that is idempotent - so the second run finds nothing
changed and skips the announcements entirely, which is how a scheduled unpublish
ends up permanently serving a page it should have expired.

The effects row is written inside the transition's transaction, so it exists if
and only if the transition committed, and it carries everything frozen rather
than re-reading a record that may have moved on.

All three have to land for the task to succeed - a listener that threw, a search
engine that refused the write, or any configured web origin that did not accept
its invalidation each fail the run, and the reasons are combined into one
`effectsError`. `EventsModel.emit()` never throws, so the task reads
`EventEmitResult.failures` rather than waiting for an exception that is not
coming.

Delivery is at-least-once: the search write and the cache expiry are idempotent,
but a listener can see one `published` twice, so a listener that must act once
keys off the `scheduleId` in the payload.

The event's envelope is stamped with the plugin that owns the **content type**,
not with core. Core owns the handler; `content.example.article.published`
belongs to the example plugin however it was triggered.

<Callout type="info" title="An announcement failure is not a publication failure">
The schedule stays `completed` and the reason lands in `effectsError` on the
schedule row. Nothing is ever moved back to `pending` because an event
bounced - the record really did publish.
</Callout>

## Retries and backoff

If a handler throws, the task is retried with an exponential backoff
Expand Down
150 changes: 145 additions & 5 deletions apps/docs/content/docs/dev/content-engine/admincp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ You get a nav item, a breadcrumb, and a screen at:
- **Pagination** - the standard cursor pagination, capped at 100 per page
- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open
- **Delete** - a confirmation dialog
- **History** - with [`editorial`](#editorial): every version, a diff, and restore
- **Empty, loading and error states** - out of the box

## What "lazy-loaded on open" actually means
Expand Down Expand Up @@ -67,14 +68,14 @@ because the frontend forgot something the backend allows:
"createdAt",
"updatedAt",
...(definition.publication.enabled ? ["status", "publishedAt"] : []),
...(definition.editorial.enabled ? ["version"] : []),
];
```

System columns need no entry in `orderableFields`, and neither do `status` and
`publishedAt` - but the last two appear **only** when
[`publication`](/docs/dev/content-engine/publication) is enabled. A Stage 1
content type with a hand-rolled `status` field sorts by it the ordinary way,
through the allowlist.
System columns need no entry in `orderableFields`, and neither do `status`,
`publishedAt` or `version` - but those three appear **only** when their block is
enabled. A Stage 1 content type with a hand-rolled `status` field sorts by it
the ordinary way, through the allowlist.

A column that is orderable but not displayed simply has no header to click.

Expand All @@ -95,6 +96,23 @@ wrong - a delete blocked by a foreign key does not read like a crashed server:
| 409 | this record is still referenced by other content |
| anything else | the generic server error |

An [editorial](/docs/dev/content-engine/editorial) content type answers those
two ambiguous statuses with a JSON `code`, so the wording follows what actually
happened rather than the number:

| Code | The person is told |
| --- | --- |
| `CONTENT_VERSION_CONFLICT` | someone else saved this while you were editing - your changes are still here |
| `CONTENT_UNIQUE_CONFLICT` | a record with these values already exists |
| `CONTENT_REVISION_NOT_RESTORABLE` | this version cannot be restored: *fields* no longer fit this content type |

A **delete** that hits `CONTENT_VERSION_CONFLICT` gets its own wording, because
the situation is different: nothing of yours is at stake, the record simply
moved. It reads *"someone saved it after this page loaded, so it was not
deleted - refresh and check what changed"*, and it deliberately does **not**
retry with the new version. A confirmation dialog cannot ask about a change
nobody has seen.

<Callout type="info" title="The database never speaks to the user">
The generated routes translate Postgres error codes into a status and a generic
sentence. Constraint names, column names and values stay on the server; the
Expand Down Expand Up @@ -144,6 +162,128 @@ publication date. It has no publish control of its own: `status` and
`publishedAt` are not in the form schema, and two competing mutation paths in
one dialog is how a form ends up fighting its own state.

## Editorial

A content type with [`editorial`](/docs/dev/content-engine/editorial) gains a
**clock** row action, an **eye** one when preview is on, and changes how the
edit dialog handles a failed save. The cell reads left to right:

```text
Preview · Schedule · History · Publish/Unpublish · Edit · Delete
```

### History

The dialog body is lazy-loaded exactly like the form, and for the same reason.
It lists one line per version - the operation as a badge, the author or
**System**, a localised date, and which fields moved - with a **Current** badge
on the newest.

Twenty-five at a time, with **Load older versions** underneath when there are
more. It appends rather than replaces, so scrolling back through a long history
never loses what you already read, and the button disappears once the last page
arrives. Retention defaults to 50 and a page to 25, so this is the ordinary case
rather than an edge one.

Restoring reloads the list in place - the restore writes a revision of its own,
and it should appear where it happened - refreshes the table behind the dialog,
and adopts the new current version, so a second restore in the same sitting does
not conflict with the first.

The list carries metadata only. Expanding a version fetches that one snapshot
and renders a field-level diff against the version before it:

| Kind | Rendered as |
| --- | --- |
| `text`, `slug` | inline, old value struck through |
| `textarea` | inline, collapsed behind a disclosure past eight lines |
| `boolean` | a tick or an em-dash |
| `enum` | a badge with the option's label |
| `number` | `tabular-nums` |
| `dateTime` | `<DateFormat>`, in the viewer's locale |
| `relation`, `user` | the stored id, as `#3` |
| `null` | the same em-dash the table cells use |

No raw JSON anywhere. Somebody comparing two versions of an article is looking
for the sentence that changed, and `{"title":"..."}` makes them find it
themselves.

<Callout type="info" title="A relation diff shows an id, not a name">
A [snapshot](/docs/dev/content-engine/revisions#the-snapshot) stores the
foreign key, deliberately - the display name belongs to another content type,
which may not publish it. Resolving those ids back to names at display time is
not wired up yet, so a changed category currently reads `#3 → #7`.
</Callout>

Restoring asks for confirmation and states all four facts: which version, that
it creates a **new** one, that nothing in between is deleted, and that the
publication state does not move. The button is absent without `can_restore`.

### Preview

With [`editorial.preview`](/docs/dev/content-engine/preview), an eye icon leads
the actions cell - present only for a content type that can be previewed, absent
rather than disabled for anything else.

The link is minted **when the popover opens**, never with the table payload. A
page of 25 rows must not be 25 live bearer credentials for unpublished records
sitting in a browser, most of them never used. Closing the popover throws the
link away, so opening it again mints a fresh one instead of showing you one that
may already have expired.

The URL is absolute - it is going on a clipboard and into somebody else's chat
window - and it points at the web app when `preview.pathTemplate` is set, or at
the API's JSON endpoint when it is not. The popover also says when the link
expires, that it is pinned to one version, and, for a record with no history
yet, that it reads live rather than frozen.

Without a usable `CONTENT_PREVIEW_SECRET` the server answers 503 and the toast
names the variable, because the person clicking the button is usually the person
who can set it.

### Scheduling

With [`editorial.scheduling`](/docs/dev/content-engine/scheduling), a calendar
icon opens a dialog showing what is booked, what already ran and who booked it,
above a two-field form: what should happen, and when.

- The date field names the timezone it is reading, because "9am" is a question
otherwise.
- An impossible date is refused before the round trip, by the same pure function
the server uses - so the two cannot drift into disagreeing.
- A pending schedule whose time has passed reads **overdue**, with the last error
if there was one.
- A completed schedule whose announcements have not landed says so in different
words and a different colour: the record *is* published, and the event, search
write and cache expiry are being retried.
- Cancelling works until the worker claims the row. After that the request
answers 404, because the schedule already ran - the dialog never claims to
have stopped something it did not.
- Without a cron adapter, a warning sits above everything: schedules will be
saved and will never fire.

Gated by `can_publish`, like the publish button - booking a publication is
publishing, just later.

### The conflict

When another session saved first, the edit dialog **stays open and keeps
everything you typed.** A banner appears above the fields; nothing is merged,
overwritten or reloaded until you say so.

1. The banner names the version the record moved to.
2. **Show what changed** loads it and lists only the fields that actually moved
remotely - compared against the values the dialog *opened* with, not against
what you have typed since, because the question being answered is "what did I
not see".
3. Saving again is a second, deliberate click, and it posts the new version.

<Callout type="info" title="Why it does not merge for you">
Deciding which half of a rewritten paragraph survives is an editorial
judgement. A field-level automatic merge would silently pick one, and be wrong
often enough that nobody could trust the result.
</Callout>

## One route, every content type

Core ships a single catch-all page that is synced into your app like any other
Expand Down
Loading
Loading