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
70 changes: 69 additions & 1 deletion apps/docs/content/docs/dev/content-engine/admincp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ You get a nav item, a breadcrumb, and a screen at:

- **List** - a `DataTable` with the columns from `admin.list.columns`
- **Search** - across `admin.list.searchableFields`, wildcards escaped
- **Sorting** - limited to `admin.list.orderableFields` plus the system columns
- **Sorting** - `admin.list.orderableFields`, plus the system columns and the
publication ones ([below](#what-is-sortable))
- **Pagination** - the standard cursor pagination, capped at 100 per page
- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open
- **Delete** - a confirmation dialog
Expand All @@ -53,6 +54,30 @@ chunks, so it is downloaded once:
milliseconds of theatre either way.
</Callout>

## What is sortable

The table header offers a sort control for every column the generated route
would accept - the two lists come from one helper, so a header is never dead
because the frontend forgot something the backend allows:

```ts
[
...definition.admin.list.orderableFields,
"id",
"createdAt",
"updatedAt",
...(definition.publication.enabled ? ["status", "publishedAt"] : []),
];
```

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.

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

## Mutation feedback

Every mutation closes its dialog, refreshes the list and raises a `sonner` toast
Expand All @@ -76,6 +101,49 @@ wrong - a delete blocked by a foreign key does not read like a crashed server:
detail goes to `core_logs`.
</Callout>

## Publication

A content type with [`publication`](/docs/dev/content-engine/publication) leads
its table with a **status** column, rendered as a badge rather than raw text:

| Status | Badge |
| --- | --- |
| `draft` | secondary, clock icon |
| `published` | default, tick icon |

`status` is also a generated filter, so `?status=draft` narrows the list.

### The publish action

Each row gains a third icon button, before Edit and Delete. It flips with the
row's state rather than showing two buttons with a dead one:

| Row | Icon | Action |
| --- | --- | --- |
| draft | paper plane | Publish |
| published | crossed-out eye | Unpublish |

It opens a confirmation dialog - publishing is outward-facing, and unpublishing
takes something away from people who can currently see it - then shows a success
toast with the row's title, or an error toast with the mapped reason. The table
refreshes either way, and the dialog stays open on failure so the reason is
still on screen next to the thing that failed.

Both routes are idempotent, so a double click is a 200 that changed nothing
rather than an error.

<Callout type="warn" title="Gated by can_publish, never by can_edit">
The button is absent for a role without `can_publish`, and the route answers
403 whether or not it was rendered. That separation is the point of the
permission: a role can be trusted to write drafts without being trusted to put
them on the internet.
</Callout>

The **edit dialog** shows the same badge as a read-only line, with the
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.

## One route, every content type

Core ships a single catch-all page that is synced into your app like any other
Expand Down
252 changes: 252 additions & 0 deletions apps/docs/content/docs/dev/content-engine/caching.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
---
title: Caching
description: Three cache-tag builders, one invalidation matrix, and a clear rule about who is allowed to expire what.
icon: Zap
---

Public content is read far more often than it is written, so it should be
cached - and then expired precisely, when the row it came from actually changed.

## The tags

```ts
import {
contentPublicItemTag,
contentPublicListTag,
contentPublicSlugTag,
} from "@vitnode/core/content";

contentPublicListTag("example.article");
// "content:example.article:list"

contentPublicItemTag("example.article", 12);
// "content:example.article:item:12"

contentPublicSlugTag("example.article", "hello-world");
// "content:example.article:slug:hello-world"
```

Format: `content:{contentTypeId}:{scope}[:{key}]`. No plugin id - a content type
id is already globally unique (`validateContentTypes` enforces it) and already
namespaced, as in `example.article`.

These are pure strings and they are **public API**. Tag your own `fetch` calls
and your own `"use cache"` functions with them and your pages get expired at the
same moment the generated ones do.

<Callout type="info" title="Long slugs are clamped, not truncated">
Next rejects a tag over 256 characters and a slug can be 160. Every builder
runs its result through the same FNV-1a fingerprint the index names use, so
two long slugs that differ only near the end still produce different tags.
Deterministic, and no new dependency.
</Callout>

## Reading

`contentPublicFetch` opts into the cache and attaches the right tags for you:

```ts title="src/app/articles/[slug]/page.tsx"
import { contentPublicFetch } from "@vitnode/core/content/next";

const { data } = await contentPublicFetch({
definition: articleContentType,
pluginId: "@vitnode/example",
slug,
});
```

| Fetch | Tags |
| --- | --- |
| list | `contentPublicListTag(id)` |
| detail by slug | `contentPublicSlugTag(id, slug)` |

A detail fetch is deliberately **not** tagged with the list tag. Publishing one
article must not throw away every article page.

<Callout type="info" title="The cache opt-in is explicit">
The request goes out with `cache: "force-cache"`. Caching in Next 16 is
opt-in: the default refetches on every request as soon as the route touches
cookies, headers or search params - and tags on a response that was never
stored expire nothing at all. Published content is exactly the case that
should be served from the cache until a mutation says otherwise, so
`contentPublicFetch` says so rather than hoping.

Only `200` responses are stored, so the 404 a draft returns is never cached and
publishing it is visible straight away.

The opt-in is on this function, **not** on `rawApiFetch`. Admin requests, and
every other call in the app, keep the behaviour they have.

</Callout>

The definition argument is typed `PublicContentTypeDefinition`, so a content
type without `publicApi` is a compile error rather than a request to
`/api/@vitnode/example/content//`.

The slug is URL-encoded into the path. A generated one never needs it, but this
is public API and the argument may come from anywhere.

## Writing: who expires what

<Callout type="warn" title="The service invalidates nothing">
`service.create()`, `update()`, `delete()`, `publish()` and `unpublish()`
change rows and return the result. They expire no cache tag, for the same
three reasons they emit no event:

- they accept `{ tx }`, so they may be inside a transaction that has not
committed - and expiring a tag for a write that then rolls back is worse than
not expiring it,
- they may be running in `apps/api`, a plain Node process with no Next runtime
at all,
- the Next cache APIs need a request scope, which a repository does not own.

If your own code drives a mutation, call `revalidateContent` yourself, from a
server action, after the transaction has committed.

</Callout>

The **generated AdminCP server actions** do own the application lifecycle: they
perform the write, wait for it, emit the event and expire the tags. That is
where invalidation lives.

## The matrix

`revalidateContent` decides from four inputs: was the row publicly reachable
before, is it reachable after, which slugs did it answer to, and which row is
it.

| Operation | list | item | old slug | new slug | How |
| --- | :-: | :-: | :-: | :-: | --- |
| create draft | — | — | — | — | — |
| update draft | — | — | — | — | — |
| publish | ✓ | ✓ | — | ✓ | immediate |
| update published, same slug | ✓ | ✓ | — | ✓ | stale-while-revalidate |
| update published, slug changed | ✓ | ✓ | ✓ | ✓ | immediate |
| unpublish | ✓ | ✓ | ✓ | — | immediate |
| delete, ever published | ✓ | ✓ | ✓ | — | immediate |
| delete, never published | — | — | — | — | — |
| publish/unpublish no-op | — | — | — | — | — |

Two things worth stating out loud:

- **A draft touches nothing.** Creating or editing one changes no public
response, so expiring a public list for it would throw away a warm cache for
free.
- **A no-op touches nothing.** Publishing something already published
transitioned nothing, so a double-clicked button costs one 200 and no cache.

Nothing global is ever expired, and one content type's mutation never touches
another's tags.

### Immediate, or stale-while-revalidate

The last column is the difference between two Next APIs, and it matters:

```ts
type ContentInvalidationMode = "immediate" | "stale-while-revalidate";

revalidateContent(input, { mode: "immediate" });
```

| Mode | Calls | The next request |
| --- | --- | --- |
| `immediate` (default) | `updateTag(tag)` | waits for fresh data |
| `stale-while-revalidate` | `revalidateTag(tag, "max")` | gets the cached response once more, while the new one is fetched behind it |

Stale-while-revalidate is safe in exactly one case: the row was public before,
is public after, and still answers to the same URL. The response that may be
served one more time is then one a visitor is allowed to see and can still
reach - it is just a few seconds out of date, and a warm cache is worth that.

Every other row of the matrix **removes** public reachability, and there the
cheaper option is simply wrong:

- an **unpublished** post would stay readable for one more request,
- a **deleted** one would answer 200 after it stopped existing,
- an **old slug** would keep resolving after the row moved.

So those expire immediately. Publishing is immediate too, though nothing is at
risk there: it means the post is live by the time the success toast appears,
rather than on the request after it.

<Callout type="warn" title="`immediate` is Server-Action-only">
`updateTag` throws outside a Server Action - that restriction is what buys
read-your-own-writes. Every generated write path is already a server action,
so the default is right there. From a Route Handler, a webhook or a cron,
pass `{ mode: "stale-while-revalidate" }`.
</Callout>

### The slug change

An update needs both slugs: the old URL has to stop resolving and the new one
has to start. The generated `PUT` returns the new row, and by then the old slug
is gone - so the server action **reads the row before writing**.

That is a deliberate design rather than a guess after the fact. Widening the
route's response to carry the previous row would change a public contract for a
cache concern, and trusting the slug the browser happens to be holding would
expire the wrong tag whenever the table was stale. One extra `GET` on a staff
edit of a *public* content type is the cheapest correct option, and it is
skipped entirely for everything else.

## Doing it yourself

```ts title="src/app/actions.ts"
"use server";

import { revalidateContent } from "@vitnode/core/content/next";

export const publishArticle = async (id: number) => {
const result = await db.transaction(async tx => {
return await articleContent.service(c).publish(id, { tx });
});

// Committed by here, so it is safe to expire anything.
if (result?.changed) {
revalidateContent({
contentTypeId: "example.article",
id,
isPublic: true,
slugs: [result.row.slug],
wasPublic: false,
});
}
};
```

This is a server action, so the default `immediate` mode applies and the article
is live the moment the call returns. From a Route Handler, add
`{ mode: "stale-while-revalidate" }` - `updateTag` is not available there.

`contentInvalidationTags` is the same decision, without the Next call - useful
if you cache somewhere else entirely:

```ts
import { contentInvalidationTags } from "@vitnode/core/content";

contentInvalidationTags({ contentTypeId, id, isPublic, slugs, wasPublic });
// → the exact list of tags, and nothing more
```

And `isContentPubliclyVisible` is the JavaScript half of `publishedCondition`,
so "was this reachable?" is answered by the same three clauses the database
enforces rather than by a second, drifting rule:

```ts
isContentPubliclyVisible({ publishedAt: row.publishedAt, status: row.status });
```

## Where the Next imports live

Exactly one place: `@vitnode/core/content/next`.

`@vitnode/core/content` and `@vitnode/core/content/server` are loaded by
`apps/api` - a plain `@hono/node-server` process - and by drizzle-kit, which
executes `src/database/*.ts` to read the tables. `next/cache` and `server-only`
both throw there, so an accidental import would not fail in CI; it would fail
when somebody ran a migration. A test walks the engine's import graph and
asserts the rule instead of trusting it.

That is why the tag builders are strings in the client-safe layer, and only
`revalidateTag`, `updateTag` and the tagged `fetch` live behind the Next
entrypoint.
Loading
Loading