feat: Add content engine Stage 2 — Publication - #733
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
`publishedCondition` took `Record<string, PgColumn>`, so passing the columns of a content type without publication compiled and then compared columns that do not exist. It now takes the two columns it actually reads, which a `ContentModel`'s column map satisfies only when `publication` is enabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`field.slug({ source: "title" })` generates a NOT NULL, unique-indexed
varchar and derives its value from a text field when the create payload
omits it. Supplied values are normalised the same way, so the rules hold
whoever wrote them.
An update never re-derives the slug - only an explicit `slug` in the
patch moves it, which is what keeps published URLs stable. Nothing
auto-suffixes: `slugify` is deterministic, uniqueness belongs to the
index, and a clash surfaces as the existing 23505 -> 409 mapping.
A slug that folds to nothing (CJK, emoji, punctuation) throws the new
`ContentInputError`, which the generated routes turn into a 400 carrying
the actionable message.
The example plugin gains a slug on `example.article`, with the two-step
backfill migration a populated table actually needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`publicApi: { enabled: true, path, fields }` opts a content type into a
generated public read surface. It requires `publication` and exactly one
exposed slug field, both checked at definition time, so enabling
publication still exposes nothing on its own.
`fields` is a strict allowlist with no wildcard, and `searchableFields`,
`filterableFields` and `orderableFields` must each be a subset of it -
that is what stops a filter or a sort being used to probe a column the
response omits. User fields are rejected outright (a user field resolves
to a person), and so is `status`, which is a constant once every row is
published.
Paths are validated as a single lowercase segment, `admin` is reserved
because the admin gate is a substring test, and two content types
claiming the same path fail at registry validation naming both plugins.
`ContentPublicSelect` carries exactly the allowlisted keys, with an
exposed relation projected to `{ id, label }`. The matching Zod schemas
are what the public SELECT will be built from, so a private field never
leaves Postgres rather than being fetched and deleted.
Named `publicApi` rather than `public`: the latter is a reserved word in
strict mode and cannot be destructured in `defineContentType`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`model.publicService(c)` is a separate object from `model.service`, not a filtered view of it: there is no create, update, delete, publish or unpublish to omit, so a public write is not something you can reach by accident. It is `undefined` unless the content type has a `publicApi`. Two invariants make it safe. The published predicate is applied inside every method rather than passed in, so there is no argument a caller can forget - `ContentPublicFindManyArgs` has no `where` and no `includeDrafts`. And the SELECT is built from `publicApi.fields`, so a private column never leaves Postgres. The single exception is `id`, which the pagination cursor reads off the row; it is dropped from the projection unless the allowlist names it, and that boundary is tested. Filters, search and ordering each go through the public allowlist rather than the admin one, so a column that is readable is not automatically queryable and a private column cannot be probed sideways. `resolveReferenceTargets` moves to `server/references.ts` so both services resolve relation labels identically, and `publicationColumns` narrows an erased column map for the predicate - the generic case Phase 0's narrower parameter type made explicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two GET routes per public content type, mounted by a top-level
`buildContentPublicModule`:
GET /api/{pluginId}/content/{publicApi.path}/
GET /api/{pluginId}/content/{publicApi.path}/{slug}
Public by omission, exactly like every other public route in VitNode: no
`adminStaffPermission` and no `/admin/` in the path, so the global admin
gate never sees them. The route tests install no session at all, which
is what makes "it answered anyway" a real assertion.
A draft, an unpublished row, a cleared publication date and a typo are
all the same 404 - a 403 would confirm the record exists.
`orderBy` is a literal enum of the public allowlist, so a column that is
orderable in the AdminCP but not published is a 400. Private fields are
absent from the filter schema entirely, so they cannot reach the query
builder even as a rejected value.
The module deliberately registers no `contentTypes`: `buildApiPlugin`
collects them recursively and a second registration would throw
"Duplicate content type id". That trap has its own test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One row action that flips with the row's state - Publish for a draft, Unpublish for a published row - rather than two buttons with a dead one. Gated by `can_publish`, never by `can_edit`, so a role can be trusted to write drafts without being trusted to put them on the internet. Confirmation dialog, loading and disabled state from the existing `ConfirmActionAlertDialog` (which now takes `submitVariant`, since `destructive` is right for a delete and wrong for a publish), success and failure toasts with a description, and a table refresh through the server action's `revalidatePath`. Both routes are idempotent, so a double click is a 200 with `changed: false` rather than an error - the button needs no guard. The edit dialog gets a read-only status line rather than a second publish control: `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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cache tags are pure strings in `@vitnode/core/content`, so an app can tag
its own fetches and `"use cache"` functions with exactly the same values
and get invalidated alongside the generated pages. `revalidateTag` lives
in the new `@vitnode/core/content/next` entrypoint, the only module in
the engine that imports `next/*` - `content/` and `content/server/` are
loaded by `apps/api` and drizzle-kit, where that throws. A test walks the
import graph and asserts the rule rather than trusting it.
Format is `content:{contentTypeId}:{scope}[:{key}]`, clamped to Next's
256-character limit with the same FNV-1a fingerprint the index names use
(now shared in `content/fingerprint.ts`), so two 160-character slugs
cannot collapse onto one tag.
`contentInvalidationTags` is pure, so the whole matrix is a table test:
a draft created or edited touches nothing, publish/unpublish/delete-once-
published expire list + item + slug, and a slug change expires the old
URL and the new one. Nothing global is ever invalidated.
Invalidation is triggered by the AdminCP server actions, after the write
returns - never by the service, which may be inside an uncommitted
transaction, may not be running under Next, and owns no request scope.
The update path reads the row *before* writing so it knows the slug it
is replacing; that read is skipped for content types with no public API.
`RawApiFetchArgs["options"]` gains an explicit `next` field: Next's
augmentation of the global `RequestInit` is not visible where the package
compiles, and widening the shared fetcher beats bypassing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four new pages - Slug field, Public API, Public Content Service and Caching - and updates to the nine existing ones that were describing a world without them. The four names are now used consistently and kept apart: Admin Content Service, Public Content Service, generated Admin API, generated public API. `service.mdx` is retitled and says which of the two it is. Three claims that were true last week and are not any more: the "publish buttons are not here yet" callout in admincp.mdx, the "no generated public read endpoint" section in limitations.mdx, and the "until the generated public read layer lands" framing in publication.mdx. Every page repeats the rule that matters: publication alone exposes nothing, public content is opt-in twice over, public writes are never generated, and a direct service call emits no event and expires no cache tag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ContentTableView` passed only `admin.list.orderableFields` to `DataTable`, so `id`, `createdAt`, `updatedAt` and - with publication - `status` and `publishedAt` had no sort control, even though the generated route has always allowed them. Both sides now read `orderableColumns(definition)`, the client-safe helper the route already builds its `orderBy` enum from, so the two lists cannot drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten findings from the PR #733 review, verified against the installed Next 16.3.0-preview.9 declarations rather than assumed. Caching - `contentPublicFetch` now sends `cache: "force-cache"`. Caching is opt-in in Next 16, so without it the tags decorated a response that was never stored. The opt-in is on this function only; `rawApiFetch` is untouched. - `revalidateContent` takes a mode. `immediate` (the default) calls `updateTag`; `stale-while-revalidate` keeps `revalidateTag(tag, "max")`. Unpublish, delete and a moved slug must not serve the old response even once, so they expire immediately; an edit to a published row that kept its URL is the one case that stays stale-while-revalidate. Public projection - An exposed relation is `{ id }`. The label came from the target's `admin.titleField` - administrative metadata that may name a field the target never publishes, on a row that may itself be a draft. The public service now joins nothing at all, so a target table is never read. Admin labels are unchanged. Correctness - The slug backfill truncates the base before appending the row id. A slug can already fill `varchar(160)`, so `slug || '-' || id` overflowed the column and failed the migration on exactly the rows the statement was rescuing. - `contentPublicFetch` takes `PublicContentTypeDefinition`, so a content type without `publicApi` is a compile error instead of a request to `/api/{pluginId}/content//`. - The detail path is `encodeURIComponent`d. Policy - `publicApi.path` collisions are scoped to `pluginId + path`. The route is `/api/{pluginId}/content/{path}`, so two plugins publishing "articles" do not collide; rejecting them failed an app's boot over a name neither author can see. Inside one plugin it is still an error. - `publicService.findById` is kept as direct-plugin API - an event payload carries a `contentId`, not a slug. It applies the predicate centrally and returns the public projection, and no numeric-id route is generated. - `publishedCondition`'s comment no longer claims the engine generates no public route. Tests - New: `fetch.server.test.ts` (cache mode, tags, path encoding), `revalidate.server.test.ts` (which Next API each mode calls). - `mutation-api.test.ts` drives the real `revalidate.server` against a mocked `next/cache` and asserts the function, not just the tag list. - The Postgres suite seeds duplicate titles, a title filling the whole column and a non-Latin one *between* migrations, so the committed backfill runs against real data, and asserts no relation label reaches a response. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The duplicate pass rescued a row by appending its id - but the value it built is itself a slug, so it could land on a natural one that was left alone: id=1 "Foo 2" -> "foo-2" unique, untouched id=2 "Foo" -> "foo" -> "foo-2" rescued onto row 1 id=3 "Foo" -> "foo" -> "foo-3" The id fallback had the same hole: a title that normalises to nothing becomes its bare id, which collides with a row whose title genuinely normalised to that number. Neither surfaced until `CREATE UNIQUE INDEX` failed two statements later, halfway through a deploy. Dropping the `WHERE` and suffixing every row removes the class of bug rather than the instance. Every value is `<base>-<id>`, or `<id>` when the title left nothing behind, so two can only be equal if their ids are - and ids are the primary key. No loop, no second pass, nothing random. The cost is that unambiguous rows are suffixed too. That is migration-only: runtime create and update still return 409 on a collision and never rewrite a URL an author chose. The Postgres suite seeds both collision scenarios with explicit ids - they only reproduce at particular ids, so the sequence must not pick them - alongside the existing long-title and non-Latin cases, and replays the committed migration over them. Restoring the old predicate fails that suite on the unique index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d33630cd09
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| primaryCursor, | ||
| orderBy: { | ||
| column: buildOrderColumn({ |
There was a problem hiding this comment.
Align cursor filtering with the public sort order
When a list is ordered by publishedAt (the default) or another configured field, withPagination still builds its cursor predicate against primaryCursor (id). Once publication order differs from ID order—for example, older records are published later—the next page filters IDs instead of values from the selected ordering, causing published records to be skipped or repeated. Use a cursor containing the ordering value plus ID as a tie-breaker, or restrict cursor pagination to ID ordering.
Useful? React with 👍 / 👎.
| .limit( | ||
| typeof limit === "number" | ||
| ? Math.min(limit, CONTENT_PUBLIC_MAX_PAGE_SIZE + 1) | ||
| : CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, |
There was a problem hiding this comment.
Honor the 25-row default page size
For a request without first or last, withPagination always supplies a numeric limit of 51, so this branch requests 51 rows and returns 50; the CONTENT_PUBLIC_DEFAULT_PAGE_SIZE fallback is unreachable. Consequently, the generated public API's default is 50 rather than the declared and documented 25 rows. Set the default before invoking withPagination or cap its numeric default to 26.
Useful? React with 👍 / 👎.
Description
What?
Completes Stage 2 of the VitNode Universal Content Engine.
It adds:
Why?
Stage 1 generated administrative CRUD infrastructure but intentionally stopped
before content could safely be published and consumed by a frontend.
Stage 2 completes the lifecycle:
Publication behavior
publishedAtrecords the first publication date.publishedAt.Slug behavior
409 Conflict.Public API security boundaries
{ id }.404.Caching
Validation
pnpm build:scriptspnpm build:pluginspnpm buildpnpm lintpnpm testpnpm test:typesDATABASE_TEST_URLOut of scope