Skip to content

feat: Add content engine Stage 2 — Publication - #733

Open
aXenDeveloper wants to merge 13 commits into
feat/Universal-Content-Engine-mvp-1from
feat/Universal-Content-Engine-mvp-2
Open

feat: Add content engine Stage 2 — Publication#733
aXenDeveloper wants to merge 13 commits into
feat/Universal-Content-Engine-mvp-1from
feat/Universal-Content-Engine-mvp-2

Conversation

@aXenDeveloper

@aXenDeveloper aXenDeveloper commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Description

What?

Completes Stage 2 of the VitNode Universal Content Engine.

It adds:

  • an opt-in draft and published lifecycle,
  • generated publication columns,
  • typed publish and unpublish operations,
  • a dedicated publishing permission,
  • deterministic slug fields,
  • an opt-in public field allowlist,
  • a read-only Public Content Service,
  • generated public list and slug-detail routes,
  • AdminCP publication actions,
  • precise public cache tags and invalidation,
  • PostgreSQL migrations and integration tests,
  • runtime and type-level coverage,
  • complete Stage 2 documentation.

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:

  1. create a draft,
  2. publish it through a dedicated permission,
  3. expose only allowlisted fields,
  4. retrieve it through a read-only public API,
  5. invalidate affected public cache entries,
  6. unpublish or delete it without exposing stale content.

Publication behavior

  • New records start as drafts.
  • Publishing is an explicit and permissioned domain operation.
  • Publish and unpublish operations are idempotent.
  • publishedAt records the first publication date.
  • Unpublishing does not clear publishedAt.
  • Generated routes emit events only for real state transitions.
  • Direct service calls emit no framework events automatically.

Slug behavior

  • Slugs are normalized deterministically.
  • A slug can be generated from a configured text field.
  • Updating the source field does not silently change an existing slug.
  • Runtime slug collisions return 409 Conflict.
  • Migration backfills may append the row ID to guarantee unique values for existing data.
  • Migration suffixing does not change runtime slug behavior.

Public API security boundaries

  • Publication alone does not expose a public route.
  • Public exposure is opt-in per Content Type.
  • Public responses contain only allowlisted fields.
  • User fields are not publicly exposable.
  • Public relation values expose only { id }.
  • Draft, unpublished and missing records return the same 404.
  • No public write routes are generated.
  • Public queries apply the publication predicate centrally.

Caching

  • Public fetches explicitly opt into the Next.js Data Cache.
  • Public responses use deterministic content tags.
  • Publish, unpublish, delete and slug changes invalidate immediately.
  • Safe updates that keep the same public URL use stale-while-revalidate.
  • Direct service calls do not invalidate caches automatically.

Validation

  • pnpm build:scripts
  • pnpm build:plugins
  • pnpm build
  • pnpm lint
  • pnpm test
  • pnpm test:types
  • PostgreSQL Content Engine integration tests through DATABASE_TEST_URL

Out of scope

  • Search Engine integration
  • scheduled publishing
  • revisions
  • localization
  • slug history and redirects
  • comments and reactions
  • advanced public relation population
  • public write APIs

@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vitnode-docs Ready Ready Preview Aug 4, 2026 10:58am

aXenDeveloper and others added 8 commits August 3, 2026 15:26
`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>
aXenDeveloper and others added 2 commits August 4, 2026 10:33
`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>
@aXenDeveloper aXenDeveloper changed the title feat: Add content engine stage 2 feat: Add Content Engine public publishing lifecycle Aug 4, 2026
@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Aug 4, 2026
@aXenDeveloper
aXenDeveloper marked this pull request as ready for review August 4, 2026 12:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +212 to +214
primaryCursor,
orderBy: {
column: buildOrderColumn({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +232 to +235
.limit(
typeof limit === "number"
? Math.min(limit, CONTENT_PUBLIC_MAX_PAGE_SIZE + 1)
: CONTENT_PUBLIC_DEFAULT_PAGE_SIZE,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@aXenDeveloper aXenDeveloper changed the title feat: Add Content Engine public publishing lifecycle feat: Add content engine Stage 2 — Publication Aug 4, 2026
@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant