diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index 01fded491..2d1b287d1 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -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 @@ -53,6 +54,30 @@ chunks, so it is downloaded once: milliseconds of theatre either way. +## 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 @@ -76,6 +101,49 @@ wrong - a delete blocked by a foreign key does not read like a crashed server: detail goes to `core_logs`. +## 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. + + + 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. + + +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 diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx new file mode 100644 index 000000000..139e621ae --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -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. + + + 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. + + +## 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. + + + 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. + + + +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 + + + `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. + + + +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. + + + `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" }`. + + +### 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. diff --git a/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx index 7ec44b46d..3e65fe613 100644 --- a/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx +++ b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx @@ -21,6 +21,14 @@ updatedAt: timestamp not null default now() // refreshed via $onUpdate plus one column per field, the indexes described [below](#indexes), and `ENABLE ROW LEVEL SECURITY`. +A content type with [`publication`](/docs/dev/content-engine/publication) gets +two more generated columns: + +```ts +status: varchar(32) not null default 'draft' +publishedAt: timestamp // "first published at", nullable +``` + Here is the real migration for the example plugin: ```sql title="apps/docs/migrations/0022_add_example_content.sql" @@ -57,15 +65,40 @@ CREATE INDEX "example_articles_updated_at_idx" ON "example_articles" USING btree Ordinary SQL. Nothing about it says "generated", which is the point. + + `status` and `publishedAt` are declared *fields* in `0022`. The article later + moved onto the generated publication columns, which is what `0023` does - and + it is worth reading as a worked example of migrating an existing table: + +```sql title="apps/docs/migrations/0023_add_publication_to_example_articles.sql" +UPDATE "example_articles" SET "status" = 'draft' + WHERE "status" NOT IN ('draft', 'published'); +ALTER TABLE "example_articles" ALTER COLUMN "status" SET DATA TYPE varchar(32); +ALTER TABLE "example_articles" ALTER COLUMN "status" SET DEFAULT 'draft'; +CREATE INDEX "example_articles_status_published_at_idx" + ON "example_articles" USING btree ("status","publishedAt"); +``` + + Drizzle Kit generated everything except the two `UPDATE`s. It cannot know + that `archived` has nowhere to go once the generated status only has two + values - that judgement is yours, and hand-editing the generated SQL is the + normal way to record it. + + ## Indexes -Four things put an index on a content table, and they are listed here in +Five things put an index on a content table, and they are listed here in precedence order: 1. anything you declared in `indexes`, -2. `field.text({ unique: true })`, +2. `field.text({ unique: true })`, and every + [`field.slug`](/docs/dev/content-engine/slug-field) - a slug is a URL, so it + is unique whether or not you ask, 3. every foreign key - `relation` and `user` fields, -4. `createdAt` and `updatedAt`, which back the default ordering. +4. `createdAt` and `updatedAt`, which back the default ordering, +5. `(status, publishedAt)` when + [`publication`](/docs/dev/content-engine/publication) is enabled - one + composite index serving both the published predicate and the ordering. ```ts indexes: [ @@ -192,6 +225,50 @@ The safe path is the same one you would use for a hand-written table: Other destructive changes to watch: lowering a `text` field's `maxLength` can fail on existing rows, and flipping `number.integer` changes the column type. +## Adding a slug to a populated table + +Drizzle Kit generates one statement for a new +[`field.slug`](/docs/dev/content-engine/slug-field): + +```sql +ALTER TABLE "example_articles" ADD COLUMN "slug" varchar(160) NOT NULL; +``` + +That fails outright on a table with rows in it - there is no default to backfill +them with. Split it by hand into add, backfill, tighten, index. The +[slug field page](/docs/dev/content-engine/slug-field#migrations) has the exact +SQL, and the example plugin's +[`0024` migration](https://github.com/aXenDeveloper/vitnode/blob/canary/apps/docs/migrations/0024_add_example_article_slug.sql) +is that file, replayed by the Postgres integration test. + + + Two rows can normalise to the same slug, and the fix is to append the row id. + Append it to **all** of them. A rescue value is itself a slug, so suffixing + only the ambiguous rows can land one on a natural slug that was left alone - + `"Foo 2"`, `"Foo"`, `"Foo"` become `foo-2`, `foo-2`, `foo-3` - and that only + surfaces when `CREATE UNIQUE INDEX` fails at the end of the migration. + + With the id on every row, two values can only match if their ids do. + + Truncate **before** appending, too: a backfilled slug can already be the full + `varchar(160)`, and `slug || '-' || id` on top of that overflows the column. + +```sql +left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text)) -- then '-' || id +``` + + The suffix is a deterministic, one-off backfill device: a migration must give + every pre-existing row a unique value with no author to ask. Runtime + collisions are a 409 instead, because the engine never rewrites a URL an + author did choose. + + +Adding `publication` to a populated table is the easier case: `status` carries +`DEFAULT 'draft' NOT NULL`, so one statement backfills it - and takes everything +out of circulation. See +[Migrations](/docs/dev/content-engine/publication#migrations) for the one-line +remediation. + Rollback works the way it does everywhere else in VitNode: there are no down migrations, so you restore from a backup or write a forward migration. diff --git a/apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx b/apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx index 7d2f19663..9bb4363d5 100644 --- a/apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx +++ b/apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx @@ -131,6 +131,29 @@ export const exampleApiPlugin = () => }); ``` +A content type with [`publicApi`](/docs/dev/content-engine/public-api) needs one +more module, and this one is **top-level** - its paths must stay out of +`/admin/`, which the global admin gate matches as a substring: + +```ts title="src/config.api.ts" +import { buildContentPublicModule } from "@vitnode/core/content/server"; + +export const exampleApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [ + adminModule, + buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [articleContent], + }), + ], + }); +``` + +It skips any content type without `publicApi`, and it registers no content types +of its own - that is the admin module's job, and doing it twice throws. + There is no `contentTypes` on `buildApiPlugin`: it walks the module tree, so the content types you listed above already drive the registry **and** the derived staff permissions. Declare once. diff --git a/apps/docs/content/docs/dev/content-engine/events.mdx b/apps/docs/content/docs/dev/content-engine/events.mdx index e3b96243b..64411d561 100644 --- a/apps/docs/content/docs/dev/content-engine/events.mdx +++ b/apps/docs/content/docs/dev/content-engine/events.mdx @@ -1,6 +1,6 @@ --- title: Generated events -description: Three typed events per content type, emitted only after a successful write. +description: Typed events per content type, emitted only after a successful write. icon: Radio --- @@ -12,6 +12,14 @@ content.example.article.updated content.example.article.deleted ``` +A content type with [`publication`](/docs/dev/content-engine/publication) emits +two more: + +```text +content.example.article.published +content.example.article.unpublished +``` + Payloads stay minimal - the [envelope](/docs/dev/events) already carries the actor, the emitting plugin and the timestamp: @@ -19,6 +27,8 @@ actor, the emitting plugin and the timestamp: type Created = { contentId: number }; type Updated = { changedFields: string[]; contentId: number }; type Deleted = { contentId: number }; +type Published = { contentId: number; publishedAt: Date }; +type Unpublished = { contentId: number }; ``` ## Registering the types @@ -78,15 +88,59 @@ unlike content types. ## When they fire +**The generated routes emit these events. The service does not.** + +That distinction matters as soon as you call the service from your own code, so +it is worth being precise: + +| | Emits | +| --- | --- | +| `POST`, `PUT`, `DELETE`, `POST /{id}/publish`, `POST /{id}/unpublish` | yes, once, after the write returns | +| `service.create()`, `update()`, `delete()`, `publish()`, `unpublish()` | no | + - Events are emitted once the database write has returned. A create that fails - validation, a delete blocked by a foreign key, and an update that changed - nothing all emit nothing at all. + A generated route emits once the database write has returned. A create that + fails validation, a delete blocked by a foreign key, an update that changed + nothing and a publish that transitioned nothing all emit nothing at all. -That last one is worth repeating: `PUT` with values identical to what is already -stored skips both the write and the event. `changedFields` never contains a -field that did not move. +That last part is worth repeating: `PUT` with values identical to what is +already stored skips both the write and the event, and `changedFields` never +contains a field that did not move. Publishing something already published +behaves the same way - a 200 with `changed: false`, and no event. + +A generated route emits exactly one event per successful mutation. `published` +and `unpublished` never come with an `updated` alongside them - `status` and +`publishedAt` are generated columns rather than declared fields, so there would +be nothing truthful to put in `changedFields`. Subscribe to all five if you want +"anything changed". + +### Calling the service directly + +The service is a repository, not an application layer. It changes rows and +returns the result; it emits nothing. + +There are two reasons for that. It accepts `{ tx }`, so it may be running inside +a transaction that has not committed - and an event emitted before the commit +would announce something that can still roll back. And it does not own the +request's event context; the route does. + +So when your own code drives a mutation, do the follow-up yourself, after the +transaction has committed: + +```ts +const result = await db.transaction(async tx => { + return await articles.service(c).publish(id, { tx }); +}); + +// Committed by here, so it is safe to tell anyone. +if (result?.changed && result.publishedAt) { + await c.get("events").emit("content.example.article.published", { + contentId: id, + publishedAt: result.publishedAt, + }); +} +``` Delivery semantics are the platform's, not the engine's: in-process by default, per-listener error isolation, no outbox. See [Events](/docs/dev/events). diff --git a/apps/docs/content/docs/dev/content-engine/fields.mdx b/apps/docs/content/docs/dev/content-engine/fields.mdx index 265bf9e15..0eb512d84 100644 --- a/apps/docs/content/docs/dev/content-engine/fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/fields.mdx @@ -1,6 +1,6 @@ --- title: Supported fields -description: The eight field kinds, and exactly what each one becomes in Postgres, in the API and in the AdminCP. +description: The nine field kinds, and exactly what each one becomes in Postgres, in the API and in the AdminCP. icon: ListChecks --- @@ -10,6 +10,7 @@ things: a column, a Zod rule, an AdminCP input and a table cell. | Field | Column | API value | AdminCP input | Sortable | Filterable | Searchable | | --- | --- | --- | --- | :-: | :-: | :-: | | `text` | `varchar(maxLength ?? 255)`, optionally unique | `string` | `AutoFormInput` | ✓ | ✓ | ✓ | +| `slug` | `varchar(maxLength ?? 160)`, `NOT NULL`, always unique | `string` | `AutoFormInput` | ✓ | ✓ | opt-in | | `textarea` | `text` | `string` | `AutoFormTextarea` | ✓ | ✗ | ✓ | | `number` | `integer` or `double precision` | `number` | number input | ✓ | ✓ | ✗ | | `boolean` | `boolean` | `boolean` | `AutoFormSwitch` | ✓ | ✓ | ✗ | @@ -53,7 +54,8 @@ views: field.number({ integer: true, defaultValue: 0 }); Same storage family, different intent. `text` is a bounded `varchar` and gets a single-line input; `textarea` is unbounded `text` and gets a multi-line one. -Only these two may appear in `searchableFields`. +These two are the default `searchableFields`; a `slug` can join them when you +ask for it. ```ts title: field.text({ required: true, minLength: 3, maxLength: 200 }), @@ -84,6 +86,21 @@ For a multi-column unique constraint, declare it there instead: indexes: [{ on: ["category", "code"], unique: true }], ``` +## slug + +A URL segment, derived from a `text` field and normalised on the way in: + +```ts +slug: field.slug({ source: "title" }), +``` + +Always `NOT NULL`, always unique-indexed, and never re-derived by an update - so +a published URL does not move because somebody fixed a title. It has no +`required` or `nullable` argument: both follow from `source`. + +[Slug field](/docs/dev/content-engine/slug-field) has the normalisation table, +the collision behaviour and the two-step migration a populated table needs. + ## number `integer` is required - there is no sensible default, and guessing would decide diff --git a/apps/docs/content/docs/dev/content-engine/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx index 4ad99ef7b..71e66973d 100644 --- a/apps/docs/content/docs/dev/content-engine/index.mdx +++ b/apps/docs/content/docs/dev/content-engine/index.mdx @@ -41,8 +41,19 @@ That gives you: - an AdminCP list with search, pagination, sorting, and create/edit/delete dialogs - with **no Next.js file to write** - `can_view` / `can_create` / `can_edit` / `can_delete` in the staff editor + (plus `can_publish` with [publication](/docs/dev/content-engine/publication)) - `content.example.article.created` / `.updated` / `.deleted` events +Two more declarations, each opt-in and each independent: + +- [`publication`](/docs/dev/content-engine/publication) adds a draft/published + lifecycle, a `can_publish` permission and a badge in the AdminCP +- [`publicApi`](/docs/dev/content-engine/public-api) adds two read-only public + routes, a strict field allowlist and + [cache tags](/docs/dev/content-engine/caching) + +Publication alone exposes nothing. Public exposure requires both. + ## What it is not diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index 7bd157e05..2f64c5295 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -14,11 +14,18 @@ other 20%, so you find out here rather than halfway through building. | One-to-one, many-to-many, polymorphic relations | Write the join table and the queries by hand | | Localised content fields | Use `core_languages_words` directly, as the blog plugin does | | Rich text, media and file fields | Hand-build the field, or store an id and resolve it yourself | -| Revisions and drafts | An `enum` status field covers simple cases | +| Revisions | Not generated; the lifecycle stops at draft/published | +| Scheduled publishing | `publish()` means now. The predicate already reads `publishedAt <= now()`, so this is additive later | | Record-level ownership ("edit your own") | Check the author in a custom route | | Field-level permissions | Split the content type, or write the route | | Bulk actions | Add a custom admin route | -| Public frontend routes | The generated API is AdminCP-only, by design | +| Public **write** routes | Never generated. Write the route yourself | +| Slug history and redirects | Keep your own table. Slugs are stable by default precisely because there is no safety net | +| Public user fields | Not exposable. Write your own route with the shape you mean | +| Public relation labels | An exposed relation is `{ id }`. `admin.titleField` is administrative metadata, so it is never published on another content type's behalf | +| Deep relation nesting in a public response | One level, one key. Anything more is a custom route | +| Origin `Cache-Control` headers | [Tags](/docs/dev/content-engine/caching) handle Next-side caching; add headers in your own route | +| Per-route rate limits | The global IP bucket applies; anything finer is yours | | Search indexing | Register a `SearchIndexer` yourself | | Automatic field renames | See below | | Unique constraints on more than one column | Declare them in `indexes`, not on the field | @@ -91,11 +98,57 @@ Postgres, when the migration runs. one in a later release. -## Public API is AdminCP-only +## Public exposure is opt-in twice over -Every generated route sits under `/admin/` and requires a staff permission. -There is no public read endpoint, and adding one is your call - fetch through -the service from your own route so you control caching and visibility. +Enabling [`publication`](/docs/dev/content-engine/publication) adds a +draft/published lifecycle, and nothing else. **No content becomes publicly +reachable because you turned it on.** Every generated route still sits under +`/admin/` behind a staff permission. + +[`publicApi`](/docs/dev/content-engine/public-api) is the block that generates +public routes, it requires publication and a slug field, and its `fields` list +is a strict allowlist with no wildcard - so a field added next month is private +until somebody publishes it deliberately. + +Public **writes** are never generated, under any configuration. + +## Non-Latin titles need an explicit slug + +`slugify` folds CJK, Cyrillic and emoji to nothing, and the engine refuses to +invent a replacement rather than producing an unmemorable URL nobody chose. Send +the slug yourself. See [Slug field](/docs/dev/content-engine/slug-field). + +## Moving a slug breaks the old URL + +There is no slug history and no automatic redirect, which is exactly why a slug +never changes on its own. The moment you send a new one, the old URL starts +answering 404 - immediately, because a slug change expires the old tag with +`updateTag` rather than letting it go stale. Keep your own redirect table if +that matters. + +A slug that is already taken is a **409** at runtime, on a create or an update +alike. The engine never appends a suffix to a URL an author chose. The `-42` +suffixes in the [slug backfill +recipe](/docs/dev/content-engine/slug-field#migrations) are a one-off, +deterministic migration device for rows that predate the column. + +## Direct service calls invalidate no cache + +`service.publish()` and friends change rows and return the result. They emit no +event and expire no cache tag: they may be inside an uncommitted transaction, +they may be running in `apps/api` where there is no Next runtime at all, and the +Next cache APIs need a request scope a repository does not own. + +Every generated write path goes through an AdminCP server action, which does all +three. A direct caller does its own follow-up, after committing - see +[Caching](/docs/dev/content-engine/caching#writing-who-expires-what). + +## The public cursor is always the row id + +`withPagination` paginates on the primary key, so a list sorted by `publishedAt` +still pages by `id`. That is pre-existing behaviour shared with every admin +list, not a public-API quirk - but it means two rows with the same +`publishedAt` can order differently between pages. ## Content types are code diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index 818cb2c6f..53139393c 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -9,6 +9,11 @@ "database-and-migrations", "schemas", "service", + "publication", + "slug-field", + "public-api", + "public-service", + "caching", "admincp", "permissions", "events", diff --git a/apps/docs/content/docs/dev/content-engine/permissions.mdx b/apps/docs/content/docs/dev/content-engine/permissions.mdx index 813996d78..3bc4cc111 100644 --- a/apps/docs/content/docs/dev/content-engine/permissions.mdx +++ b/apps/docs/content/docs/dev/content-engine/permissions.mdx @@ -1,6 +1,6 @@ --- title: Generated permissions -description: The four staff permissions every content type gets, how the module name is derived, and how to override them. +description: The staff permissions every content type gets, how the module name is derived, and how to override them. icon: Lock --- @@ -17,6 +17,17 @@ can_delete DELETE The three write permissions depend on `can_view`, so a role cannot be given the ability to create rows it is not allowed to see. +A content type with [`publication`](/docs/dev/content-engine/publication) gets a +fifth, on the same `can_view` dependency: + +```text +can_publish POST /{id}/publish and /{id}/unpublish +``` + +It is deliberately not folded into `can_edit`: publishing is the only generated +operation that changes what people outside the AdminCP can see, so "may write +drafts" and "may make them public" stay separate answers. + ## What each route documents Every status a generated handler can produce is in the OpenAPI document, so a @@ -30,6 +41,8 @@ unique-constraint `409`: | `GET /{id}` | `200`, `400`, `404` | | `POST /` | `201`, `400`, `409` | | `PUT /{id}` | `200`, `400`, `404`, `409` | +| `POST /{id}/publish` | `200`, `400`, `404` | +| `POST /{id}/unpublish` | `200`, `400`, `404` | | `DELETE /{id}` | `200`, `400`, `404`, `409` | A `409` on create or update is a duplicate value; on delete it is a row something @@ -88,7 +101,8 @@ the flat key convention: "@vitnode/example:example_articles:can_view": "View articles", "@vitnode/example:example_articles:can_create": "Create articles", "@vitnode/example:example_articles:can_edit": "Edit articles", - "@vitnode/example:example_articles:can_delete": "Delete articles" + "@vitnode/example:example_articles:can_delete": "Delete articles", + "@vitnode/example:example_articles:can_publish": "Publish and unpublish articles" } ``` @@ -100,7 +114,7 @@ Three places, and the first one is the one that counts: by `assertStaffPermission` before the handler runs. 403 otherwise. 2. **The AdminCP page** checks `can_view` server-side and calls `notFound()`. 3. **The buttons** hide when the admin lacks `can_create` / `can_edit` / - `can_delete`. + `can_delete` / `can_publish`. Hiding a button is a courtesy, not a control. Removing `can_delete` from a role makes `DELETE` return 403 whether or not the button was rendered. diff --git a/apps/docs/content/docs/dev/content-engine/public-api.mdx b/apps/docs/content/docs/dev/content-engine/public-api.mdx new file mode 100644 index 000000000..bdcb7f3ec --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/public-api.mdx @@ -0,0 +1,284 @@ +--- +title: Public API +description: Opt a content type into two generated read-only routes, with a strict field allowlist and no way to reach a draft. +icon: Globe +--- + +[Publication](/docs/dev/content-engine/publication) gives content a draft state. +It does not put anything on the internet. `publicApi` is the block that does, +and it is the only one that does. + +```ts title="src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + code: field.text({ required: true, maxLength: 100, unique: true }), + views: field.number({ integer: true, defaultValue: 0 }), + author: field.user(), + category: field.relation({ required: true, target: () => categoryContentType }), + }, + + publication: { enabled: true }, + + publicApi: { // [!code highlight] + enabled: true, // [!code highlight] + path: "articles", // [!code highlight] + fields: ["title", "slug", "excerpt", "category", "publishedAt"], // [!code highlight] + searchableFields: ["title", "excerpt"], // [!code highlight] + orderableFields: ["publishedAt", "title"], // [!code highlight] + filterableFields: ["category"], // [!code highlight] + defaultOrderBy: "publishedAt", // [!code highlight] + defaultOrder: "desc", // [!code highlight] + }, // [!code highlight] + + admin: { label: { plural: "Articles", singular: "Article" } }, +}); +``` + +`code`, `views` and `author` are not in `fields`, so they never leave Postgres. + + + `public` is a reserved word in strict-mode JavaScript, and + `defineContentType` destructures its argument - `const { public } = …` does + not parse. `publicApi` also reads better next to "generated public API". + + +## What you get + +| | | +| --- | --- | +| Two routes | `GET /content/{path}/` and `GET /content/{path}/{slug}` | +| A service | [`model.publicService(c)`](/docs/dev/content-engine/public-service) | +| A projection | `ContentPublicSelect` - exactly the allowlist | +| OpenAPI | two `get` operations, no others | +| Cache tags | [three builders](/docs/dev/content-engine/caching) | + +## Two prerequisites + +`publicApi.enabled` requires both, and says so at definition time: + +1. **`publication: { enabled: true }`.** A public API without a draft state + would put every row on the internet the moment it was created. +2. **Exactly one slug field in `fields`.** It is what the detail route resolves + by. A content type may declare several slugs; expose one. + +```ts +// Error: publicApi needs `publication: { enabled: true }`. +// Error: publicApi.fields must expose exactly one slug field. +``` + +## Registering the routes + +`buildContentPublicModule` is a **top-level** module, unlike the admin one: + +```ts title="src/config.api.ts" +import { buildContentPublicModule } from "@vitnode/core/content/server"; + +export const exampleApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [ + adminModule, + buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [articleContent, categoryContent], + }), + ], + }); +``` + +Pass every model you have - a content type without `publicApi` is skipped, so +both module builders can take the same array. + + + `buildApiPlugin` walks the module tree and collects `contentTypes` + recursively. Only `buildContentAdminModule` registers them; if this module did + too, `validateContentTypes` would throw *"Duplicate content type id"*. + + +## The path + +One lowercase URL segment: `/^[a-z][a-z0-9-]*$/`, at most 64 characters. No +slashes, which rules out a leading or trailing one, an empty segment and `..` by +construction rather than by three more checks. + +`admin` is reserved. The global admin gate is a `path.includes("/admin/")` +substring test, so a public route under that name would demand a staff session +and never be public at all. + +Paths are unique **within a plugin**, exactly like permission modules and for +the same reason: the route is `/api/{pluginId}/content/{path}`, so the plugin id +already separates two of them. + +```text +/api/@acme/first/content/articles ← both fine +/api/@acme/second/content/articles +``` + +Two plugins publishing `articles` is normal, and it works. Rejecting it would +fail an app's boot over a name neither author can see, and force one of them to +rename a public URL because of a plugin they have never heard of. + +Two content types in *one* plugin claiming the same path really would collide, +so that is an error, and it names both: + +```text +Public path "articles" is claimed by both @acme/first -> first.article +and @acme/first -> first.post. Give one of them a different +`publicApi.path`. +``` + +## The field allowlist + +`fields` is strict and there is no wildcard. A field added to the content type +next month is private until somebody lists it. + +**Allowed:** `text`, `textarea`, `number`, `boolean`, `enum`, `dateTime`, `slug` +and `relation` fields, plus the generated columns `id`, `createdAt`, +`updatedAt` and `publishedAt`. + +**Refused, with a reason:** + +| Not exposable | Why | +| --- | --- | +| `user` fields | A user field resolves to a person. Publishing one should never be a one-word change - write your own route with the shape you mean | +| `status` | Every row the public API returns is published, so it would be a constant | +| Anything not declared | It is not a column | + +`id` is only in the response when you list it. It is always *fetched*, because +the pagination cursor is read off the row - and then dropped again. + +### Relations + +An exposed relation comes back as an identifier, and nothing else: + +```jsonc +"category": { "id": 3 } +``` + +A nullable relation is `{ "id": 3 }` or `null`. + + + The obvious label would be the target's `admin.titleField` - and that is + administrative metadata, not a public field. It may name a column the target + does not expose publicly, the target may have no `publicApi` at all, and the + row it would be read from may itself be a draft. One content type's allowlist + does not get to make that call for another's. + +An id is enough to fetch the related row through **its own** public API, which +is the layer that decides what it is willing to say. Configurable public +relation labels may arrive in a later stage. + + The AdminCP is unaffected: relation pickers and list rows still show labels, + because [the Admin Content Service](/docs/dev/content-engine/service) still + resolves them. + + +One level, one key. There is no deep nesting and no arbitrary population - that +is the point at which a REST projection turns into GraphQL, and a hand-written +route is the better answer. + +The public service reads the foreign key already on the row, so it **joins +nothing**. A target table is never touched, which is what makes the rule above +structural rather than a filter applied after the fact. + +## Search, filters and ordering + +Each is a separate allowlist, each defaults to empty, and each must be a subset +of `fields`. That subset rule is not tidiness: it is what stops a filter or a +sort being used to probe a column the response leaves out. + +| Key | Default | Extra rule | +| --- | --- | --- | +| `searchableFields` | none | `text`, `textarea` or `slug` only | +| `filterableFields` | none | must be an equality-filterable kind | +| `orderableFields` | none | `publishedAt` is always allowed anyway | +| `defaultOrderBy` | `publishedAt` | must be orderable | +| `defaultOrder` | `desc` | | + + + `filterableFields` is deliberately separate from `fields`, matching how + `admin.list.orderableFields` and `searchableFields` already work. Treating + every public field as a filter would mean every new public field silently + became a query parameter and an index-less scan. + + +## The routes + +```http +GET /api/@vitnode/example/content/articles/ +GET /api/@vitnode/example/content/articles/hello-world +``` + +No staff session, no permission, no `/admin/` in the path - public by omission, +exactly like every other public route in VitNode. The global middleware still +runs, so `c.get("user")` is populated (possibly `null`) and the IP rate limiter +still applies. + +### List + +```jsonc +{ + "edges": [ + { + "title": "Hello", + "slug": "hello", + "excerpt": null, + "category": { "id": 3 }, + "publishedAt": "2026-08-03T10:00:00.000Z" + } + ], + "pageInfo": { + "totalCount": 1, "count": 1, + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": 12, "endCursor": 12 + } +} +``` + +Query parameters: `cursor`, `first`, `last`, `search`, `order`, `orderBy`, plus +one per `filterableFields`. Pages are capped at 50. + +`orderBy` is a literal enum of the public allowlist, so a column that is +orderable in the AdminCP but not published is a **400**. Everything else it does +not recognise - a stale bookmark, a tracking parameter - is ignored rather than +rejected. + +### Detail + +**Resolved by slug only.** There is no generated numeric-id detail route, and +adding one is not a flag. Slug URLs are stable and non-enumerable; an id route +would let anybody walk the table. The +[Public Content Service](/docs/dev/content-engine/public-service#findbyid) does +expose `findById` for plugin code that already holds one. + +| Status | When | +| --- | --- | +| 200 | a published record | +| 404 | a draft, an unpublished record, a cleared publication date, or a typo | + + + All four cases return the same body. A 403 would confirm the record exists, + which is exactly what a draft URL must not do. + + +## OpenAPI + +The operations land in the same `/api/swagger/doc` as everything else, with the +response schema generated from the projection. Only `get` operations are ever +built - there is no public create, update, delete, publish or unpublish, and no +flag that would add one. + +## What this is not + +No public writes, ever. No `Cache-Control` headers - HTTP caching is handled +Next-side by [tags](/docs/dev/content-engine/caching). No per-route rate limits +beyond the global IP bucket. And the cursor is always the row id even when you +sort by something else, which is pre-existing `withPagination` behaviour rather +than a public-API quirk. See +[Limitations](/docs/dev/content-engine/limitations). diff --git a/apps/docs/content/docs/dev/content-engine/public-service.mdx b/apps/docs/content/docs/dev/content-engine/public-service.mdx new file mode 100644 index 000000000..61b264148 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/public-service.mdx @@ -0,0 +1,193 @@ +--- +title: Public Content Service +description: The read-only repository behind the generated public routes - three methods, no writes, and no way to return a draft. +icon: BookOpen +--- + +`model.publicService(c)` is the Public Content Service: the read half of a +content type, projected to the public allowlist. + +```ts +const articles = articleContent.publicService?.(c); +``` + +It is `undefined` unless the content type has +[`publicApi`](/docs/dev/content-engine/public-api). That is the runtime check, +and it reads naturally in a route builder that does not know which content type +it was handed. + + + The **Admin Content Service** (`model.service(c)`) is the full repository - + every column, every write, `{ tx }` support. The **Public Content Service** is + a different object with three read methods. It is not a filtered view of the + admin one, so there is no write to accidentally reach. + + +## The methods + +```ts +await articles.findMany({ filters, orderBy, query }); +await articles.findBySlug("hello-world"); +await articles.findById(12); +``` + +That is the entire surface. There is no `create`, `update`, `delete`, `publish`, +`unpublish` or `options` - and no `{ tx }`, because a read on behalf of an +anonymous visitor is not part of anybody's transaction. + +`findBySlug` is what the generated detail route uses. + +### `findById` + +Supported, and deliberately kept - but it is **direct-plugin API only**. + +```ts +// A `published` listener gets `{ contentId }` and nothing else. +const row = await articles.findById(event.contentId); +``` + +An event payload carries a `contentId`, not a slug, so without this every +listener would look the slug up first through the admin service - reading more +columns than it wanted, to reach a row the public service could have given it. + +It is the same three things `findBySlug` is: the published predicate applied +inside the method, the public projection on the way out, `null` for anything +that is not currently published. + + + `GET /content/articles/{slug}` is the only generated detail endpoint. Slug + URLs are stable and non-enumerable; an id route would let anybody walk the + table one integer at a time. `findById` is reachable from your own code, not + from the internet - and if you build a route on top of it, that decision is + yours to make on purpose. + + +## The published invariant + +Every method applies the same predicate, and it is **not a parameter**: + +```sql +status = 'published' AND published_at IS NOT NULL AND published_at <= NOW() +``` + +`ContentPublicFindManyArgs` has no `where`, no `includeDrafts` and no escape +hatch. There is no argument a caller can forget, so there is no code path that +reaches an unpublished row. + +All three clauses matter: + +- checking only `status` would serve a row whose timestamp was cleared by hand, +- `<= NOW()` is what will make scheduled publishing additive rather than a + rewrite. + +```ts +// Every one of these is null for a draft, an unpublished row, a row whose +// publication date was cleared, and a row dated in the future. +await articles.findBySlug("some-draft"); // null +await articles.findById(draftId); // null +``` + +## The projection + +The `SELECT` is built from `publicApi.fields`, so a private column is never +fetched - not fetched and then deleted. + +```ts +const article = await articles.findBySlug("hello-world"); + +article?.title; // string +article?.category; // { id: number } +article?.views; // compile error: not exposed +``` + +`ContentPublicSelect` has exactly the allowlisted +keys, so a private field is absent at compile time as well as at runtime. + + + `id`, because `withPagination` reads the cursor off the row. It is removed + again before the row is returned, unless `publicApi.fields` names it. That is + the whole projection boundary, and it has its own test. + + +### It joins nothing + +An exposed relation is `{ id }`, read straight off the foreign key the row +already carries. No target table is queried, so a +[target's `admin.titleField`](/docs/dev/content-engine/public-api#relations) +cannot reach a public response even by accident. + +The Admin Content Service still resolves labels the way it always has - one +`LEFT JOIN` per reference, into `labels` - because that is where they belong. + +## Filtering, search and ordering + +Each goes through the public allowlist, not the admin one: + +```ts +await articles.findMany({ + filters: { category: 3 }, + orderBy: { column: "publishedAt", order: "desc" }, + query: { search: "hello", first: "10" }, +}); +``` + +- A filter outside `publicApi.filterableFields` throws, even if the field is + public. Readable is not queryable. +- Search runs `ilike` across `publicApi.searchableFields`, wildcards escaped. + Because those must be a subset of `fields`, search cannot be used as an oracle + for a hidden column. +- Ordering outside `publicApi.orderableFields` throws - including columns the + AdminCP list can happily sort by. +- Pages default to 25 and are capped at 50, below the admin limit. + +## Using it from your own route + +The public service is a public API. If the two generated routes are not the +shape you want, build your own on top of it and keep the invariant: + +```ts title="src/api/modules/feed/routes/rss.route.ts" +handler: async c => { + const articles = articleContent.publicService?.(c); + if (!articles) throw new HTTPException(404); + + const { edges } = await articles.findMany({ + orderBy: { column: "publishedAt", order: "desc" }, + query: { first: "20" }, + }); + + return c.body(renderRss(edges), 200, { "Content-Type": "application/rss+xml" }); +}; +``` + +Nothing here can leak a draft, because nothing here can turn the predicate off. + + + Reads are free of this, but if your own code *writes* through + `model.service(c)`, no cache tag is expired: the service may be inside an + uncommitted transaction, and it may be running in `apps/api` where the Next + cache APIs do not exist. Call + [`revalidateContent`](/docs/dev/content-engine/caching#doing-it-yourself) + from your server action after the transaction commits. + + +## If you need more than it does + +Drop to Drizzle, and bring the predicate with you: + +```ts +import { publicationColumns, publishedCondition } from "@vitnode/core/content/server"; + +const rows = await c + .get("db") + .select({ id: articleContent.table.id }) + .from(articleContent.table) + .where(publishedCondition(publicationColumns(articleContentType, articleContent.columns))); +``` + +`publishedCondition` takes the two columns it reads, so passing a content type +without publication is a compile error rather than a query against columns that +do not exist. `publicationColumns` is the runtime step for generic code, where +the column map has been widened to `Record`. + +Selecting only the columns you mean to expose is yours to get right on that +path - which is the reason the generated one exists. diff --git a/apps/docs/content/docs/dev/content-engine/publication.mdx b/apps/docs/content/docs/dev/content-engine/publication.mdx new file mode 100644 index 000000000..6050abf2f --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/publication.mdx @@ -0,0 +1,281 @@ +--- +title: Publication +description: Opt a content type into the draft and published lifecycle, with two generated columns, two service methods and its own staff permission. +icon: SendHorizontal +--- + +Most content is not ready the moment someone hits Save. `publication` gives a +content type a draft state, a publish action, and a date that says when it first +went live - without you writing any of it. + +```ts title="src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + fields: { + title: field.text({ required: true }), + excerpt: field.textarea({ nullable: true }), + }, + publication: { enabled: true }, // [!code highlight] + admin: { label: { plural: "Articles", singular: "Article" } }, +}); +``` + +That is the whole configuration. There is no second option yet, and `enabled` +has to be the literal `true` - a variable typed `boolean` will not do, because +every generated type keys off that literal. + +## What you get + +| | | +| --- | --- | +| Two columns | `status` (`draft` \| `published`) and `publishedAt` | +| Two service methods | `service.publish(id)` and `service.unpublish(id)` | +| Two routes | `POST /{id}/publish` and `POST /{id}/unpublish` | +| Two events | `content..published` and `content..unpublished` | +| One permission | `can_publish` | +| One index | `(status, publishedAt)` | +| A status column | first in the AdminCP table, and filterable | + +Leave `publication` out and none of that is generated. A Stage 1 content type +carries on exactly as before. + +## The two columns are generated, not declared + +`status` and `publishedAt` are system columns, like `id` and `updatedAt`. They +show up in every response and in `filters`, and they are **absent from the +create and update schemas**: + +```ts +await service.create({ title: "Hello", status: "published" }); +// ^ compile error, and a 400 over HTTP +``` + +That is the point. Publishing is a decision, not a field edit, so it happens +through one operation that can be permissioned, audited and reacted to. + + + Once `publication` is enabled, declaring a field called `status` or + `publishedAt` is an error at definition time. Without it, both names stay + yours - which is exactly how content types written before this feature keep + working. + + +## Publishing + +```ts +const result = await service.publish(article.id); + +result?.changed; // false if it was already published +result?.publishedAt; // when it first went live +result?.row; // the full row +``` + +`null` means there is no such record. The route turns that into a 404 and +everything else into a 200. + +Both methods are **idempotent**. Publishing something already published issues +no write at all - it just tells you `changed: false`. Double-clicking the button +is harmless, and so is a retry. (Through the generated route, that also means no +event; see [Events](#events) below.) + +They take an optional transaction handle, like the other write methods: + +```ts +await db.transaction(async tx => { + await service.update(id, { title }, { tx }); + await service.publish(id, { tx }); +}); +``` + +### `publishedAt` is written once + +It is stamped on the first `draft → published` transition and **never rewritten**: + +```text +publish() status=published publishedAt=2026-08-03 (stamped) +unpublish() status=draft publishedAt=2026-08-03 (kept) +publish() status=published publishedAt=2026-08-03 (still the original) +``` + +Unpublishing to fix a typo should not move an article to the top of a +"newest first" feed three months later, so it does not. Read `publishedAt` as +*first published at*, not as *is published* - `status` answers that one. + +## Permissions + +Publication adds a fifth permission to the generated set: + +```text +can_view can_create can_edit can_delete can_publish +``` + +`can_publish` depends on `can_view`, like the others. It is separate from +`can_edit` on purpose: publishing is the only generated operation that changes +what people outside the AdminCP can see, so a role can be allowed to write +drafts without being allowed to make them public. + + + Adding `publication` to an existing content type creates a permission that is + unset on every existing role. Tick it in **Staff** before anyone can publish. + + +## Events + +```text +content.example.article.published { contentId, publishedAt } +content.example.article.unpublished { contentId } +``` + +The generated publish and unpublish routes emit exactly one matching event after +a successful state transition. A no-op - publishing something already published +- transitions nothing, so it emits nothing. + +Neither route also emits `updated`. `changedFields` on that event lists declared +fields, and these two columns are not declared ones, so an `updated` alongside +them would have nothing honest to put in it. A listener that cares about any +change subscribes to all of them. + + + `service.publish()` and `service.unpublish()` change the database and return + the transition result. They do **not** emit a framework event. + +That is deliberate. The service accepts `{ tx }`, so it may be running inside a +transaction that has not committed - and an event emitted before the commit +would announce something that might still roll back. It also has no claim on +the request's event context, which the route owns. + +If your own code calls the service directly and something needs to happen +afterwards, do it yourself once the transaction has committed: + +```ts +const result = await db.transaction(async tx => { + return await service.publish(id, { tx }); +}); + +if (result?.changed && result.publishedAt) { + await c.get("events").emit("content.example.article.published", { + contentId: id, + publishedAt: result.publishedAt, + }); +} +``` + + + +See [Generated events](/docs/dev/content-engine/events). + +## Filtering and ordering + +`status` joins the generated filters, and both columns are always orderable - +they need no entry in `admin.list.orderableFields`: + +```http +GET /api/@vitnode/example/admin/content/articles/?status=draft&orderBy=publishedAt&order=desc +``` + +## Migrations + +`status` is `varchar(32) DEFAULT 'draft' NOT NULL`, so Drizzle Kit backfills an +existing table in one statement: + +```sql +ALTER TABLE "example_articles" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL; +ALTER TABLE "example_articles" ADD COLUMN "published_at" timestamp; +CREATE INDEX "example_articles_status_published_at_idx" + ON "example_articles" ("status","published_at"); +``` + + + That default applies to rows you already have, which means adding + `publication` to a populated table takes everything out of circulation. If + that is not what you want, hand-add one line to the generated migration: + +```sql +UPDATE "example_articles" SET "status" = 'published', "published_at" = "created_at"; +``` + + + +If the table already has a `status` column of your own, the definition will +refuse to load until you rename it - the engine will not silently adopt a column +it did not create. The [example plugin's migration](https://github.com/aXenDeveloper/vitnode/blob/canary/apps/docs/migrations/0023_add_publication_to_example_articles.sql) +shows the other half of that story: it moved a hand-rolled `draft | published | +archived` enum onto the generated columns, and had to decide what happened to +the `archived` rows. + +## Serving published content + + + Enabling `publication` adds the lifecycle. It does not add a public endpoint. + Every generated route still sits under `/admin/` and still requires a staff + permission, exactly as before. + + +To actually publish content to the internet, add +[`publicApi`](/docs/dev/content-engine/public-api). That block - and only that +block - generates the two read-only routes and the +[Public Content Service](/docs/dev/content-engine/public-service) behind them. +Both apply the published predicate centrally, so there is no argument a caller +can forget. + +If the generated shape is not the one you want, the predicate is exported so the +part that is easy to get wrong is not written by hand: + +```ts title="src/api/modules/articles/routes/get.route.ts" +import { + publicationColumns, + publishedCondition, +} from "@vitnode/core/content/server"; + +import { articleContentType } from "@/content/article"; +import { articleContent } from "@/database/articles"; + +handler: async c => { + const rows = await c + .get("db") + .select({ + id: articleContent.table.id, + title: articleContent.table.title, + publishedAt: articleContent.table.publishedAt, + }) + .from(articleContent.table) + .where( + publishedCondition( + publicationColumns(articleContentType, articleContent.columns), + ), + ); + + return c.json({ rows }, 200); +}; +``` + +`publishedCondition` compiles to: + +```sql +status = 'published' AND published_at IS NOT NULL AND published_at <= NOW() +``` + +All three clauses matter. Checking only `status` would serve a row whose +timestamp was cleared by hand, and the `<= NOW()` is what will make scheduled +publishing additive rather than a rewrite. It is one definition, so a custom +route and a generated one can never disagree about what "published" means. + +It takes the two columns it reads, so handing it a content type without +publication is a compile error rather than a query against columns that do not +exist. `publicationColumns` is the runtime narrowing for generic code, where the +column map has been widened to `Record`. + +Selecting only the columns you mean to expose is your responsibility on that +path - which is exactly what the +[public field allowlist](/docs/dev/content-engine/public-api#the-field-allowlist) +does for you on the generated one. + +## What this is not + +Scheduled publishing, approval workflows and revisions are not here. The +published predicate already reads `publishedAt <= now()`, so scheduling can be +added later without changing what is stored - but today `publish()` means +*now*. + +[Limitations](/docs/dev/content-engine/limitations) tracks the rest. diff --git a/apps/docs/content/docs/dev/content-engine/schemas.mdx b/apps/docs/content/docs/dev/content-engine/schemas.mdx index 6b197dd17..d8b65edcd 100644 --- a/apps/docs/content/docs/dev/content-engine/schemas.mdx +++ b/apps/docs/content/docs/dev/content-engine/schemas.mdx @@ -1,6 +1,6 @@ --- title: Generated schemas -description: The seven Zod schemas every content type exposes, and the rules they enforce. +description: The thirteen Zod schemas every content type exposes, and the rules they enforce. icon: ShieldCheck --- @@ -19,6 +19,17 @@ articleContentType.schemas.params; // { id } articleContentType.schemas.form; // AutoForm-safe variant ``` +A content type with [`publicApi`](/docs/dev/content-engine/public-api) carries +five more, all empty without it: + +```ts +articleContentType.schemas.publicSelect; // the public projection +articleContentType.schemas.publicSelectObject; // the same, extendable +articleContentType.schemas.publicFilters; // one key per filterableFields +articleContentType.schemas.publicOrder; // the public orderBy allowlist +articleContentType.schemas.publicParams; // { slug } +``` + ## create Built from the fields, with `strictObject` so an unknown key is an **error** @@ -63,6 +74,30 @@ Describes the response, including `id`, `createdAt` and `updatedAt`. Dates are `selectObject` is the same schema left as a `ZodObject`, which is what the list route extends with the joined relation labels. +With [publication](/docs/dev/content-engine/publication) it also carries +`status` and `publishedAt`. Both are absent from `create` and `update`, which +are strict - so publishing is only ever reachable through `service.publish` and +its route, never through a field update. + +## publicSelect + +The public projection: exactly `publicApi.fields`, and not one key more. An +exposed relation collapses to `{ id }` - no label, because the only one on offer +is the target's [administrative +`titleField`](/docs/dev/content-engine/public-api#relations). + +```ts +Object.keys(articleContentType.schemas.publicSelectObject.shape); +// ["title", "slug", "excerpt", "category", "publishedAt"] +``` + +This is also what the Public Content Service builds its `SELECT` map from, so a +field missing here is a field that never leaves Postgres - not one that is +fetched and then deleted. `publicFilters` and `publicOrder` are the matching +allowlists, each a subset of `fields`, and `publicParams` is `{ slug }`. + +See [Public API](/docs/dev/content-engine/public-api). + ## filters and order Both are allowlists derived from the definition, and both exist to keep request diff --git a/apps/docs/content/docs/dev/content-engine/service.mdx b/apps/docs/content/docs/dev/content-engine/service.mdx index 239bd5dbb..89e8bf29a 100644 --- a/apps/docs/content/docs/dev/content-engine/service.mdx +++ b/apps/docs/content/docs/dev/content-engine/service.mdx @@ -1,17 +1,25 @@ --- -title: Service API +title: Admin Content Service description: The typed repository every content type exposes, bound to the request's database handle. icon: Wrench --- -`model.service(c)` returns a small typed repository for one content type. It is -deliberately thin: it owns validation, column allowlisting, pagination and -relation label joins, and hands everything else to Drizzle. +`model.service(c)` returns the **Admin Content Service**: a small typed +repository for one content type. It is deliberately thin - it owns validation, +column allowlisting, pagination and relation label joins, and hands everything +else to Drizzle. ```ts const articles = articleContent.service(c); ``` + + This one reads and writes every column, and backs the generated Admin API. The + [Public Content Service](/docs/dev/content-engine/public-service) is a + different object: read-only, projected to the public allowlist, and unable to + return a draft. Neither is a filtered view of the other. + + ## It is safe to call directly The service is a public API, not an internal helper for the generated routes. So @@ -221,6 +229,30 @@ Returns the deleted row, or `null` if there was nothing to delete. A row still referenced by a `restrict` foreign key raises a Postgres error the generated route maps to 409. +## publish and unpublish + +Only on a content type with +[`publication`](/docs/dev/content-engine/publication) - on any other one they do +not exist, and calling them is a compile error rather than a runtime surprise. + +```ts +const result = await articles.publish(7); + +result?.changed; // false if it was already published: no write happened +result?.publishedAt; // when it first went live, never rewritten +result?.row; // the full row +``` + +`null` means no such record, exactly like `update` and `delete`. Both methods +are idempotent, both accept `{ tx }`, and `unpublish` deliberately leaves +`publishedAt` alone. + +Like every other service method, they emit no event - the +[generated routes do that](/docs/dev/content-engine/events#calling-the-service-directly) - +and they expire no [cache tag](/docs/dev/content-engine/caching) either. A +service call that runs inside your transaction cannot honestly announce anything +until you commit, so the follow-up is yours. + ## options Backs the relation and user pickers, capped and search-filtered. It only accepts @@ -253,6 +285,26 @@ await c.get("events").emit("content.example.article.created", { }); ``` +## Slugs + +A [`field.slug`](/docs/dev/content-engine/slug-field) is normalised by the +service, not by the route - so the rules hold whoever wrote the value: + +```ts +await articles.create({ title: "Hello World", category: 1 }); +// slug: "hello-world", derived from the source field + +await articles.create({ title: "Hello", slug: " My Slug! ", category: 1 }); +// slug: "my-slug", supplied and normalised + +await articles.update(7, { title: "A new title" }); +// slug: unchanged. Only an explicit `slug` in the patch moves it. +``` + +A value that normalises to nothing throws a `ContentInputError`, which the +generated routes turn into a **400** carrying the actionable message. A +collision is the unique index's job and comes back as a **409**. + ## Escape hatches The service is not a wall. `model.table` and `model.columns` are public, and diff --git a/apps/docs/content/docs/dev/content-engine/slug-field.mdx b/apps/docs/content/docs/dev/content-engine/slug-field.mdx new file mode 100644 index 000000000..802415ca3 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/slug-field.mdx @@ -0,0 +1,259 @@ +--- +title: Slug field +description: A URL segment that is generated from your title, normalised on the way in, unique by construction, and never moves on its own. +icon: Link2 +--- + +A slug is the part of a URL a person can read: `/articles/hello-world` rather +than `/articles/482`. `field.slug` makes one out of an existing text field. + +```ts title="src/content/article.ts" +fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), // [!code highlight] +} +``` + +That is the whole configuration for the common case. Create an article called +"Hello World" and its slug is `hello-world`. + +## What you get + +| | | +| --- | --- | +| A column | `varchar(160)`, `NOT NULL`, no default | +| An index | `__key`, always unique | +| Derivation | filled in from `source` when the create payload omits it | +| Normalisation | applied to every value, yours or derived | +| A filter | equality, so `?slug=hello-world` works | + +## Options + +```ts +field.slug({ + source: "title", // a `text` field on the same content type + maxLength: 160, // varchar length and the truncation point + description: "Used in the public URL", +}); +``` + +There is no `required` argument, and no `nullable` one either. + +- **`nullable`** is always `false`. A row nobody can address by URL is not worth + allowing, and there is no sensible default URL to fall back on. +- **`required`** is exactly `source === undefined`. With a source the engine can + always derive the value, so the payload may omit it; without one, nobody else + can supply it, so it is mandatory. Making it a second knob would let the two + settings contradict each other - "always send it" alongside "derive it for + me". + +`source` must name a `text` field on the same content type. A `textarea`, a +number or another slug is a definition-time error: + +```ts +slug: field.slug({ source: "body" }); +// Error: sourced from "body", which is a "textarea" field. +// A slug can only be derived from a text field. +``` + +## Normalisation + +Every value is normalised before it is written - the one you sent and the one +derived from the source field alike, so the same rules hold whoever wrote it. + +| Input | Slug | +| --- | --- | +| `"Hello World"` | `hello-world` | +| `" Hello World "` | `hello-world` | +| `"hello---world"` | `hello-world` | +| `"Hello, World! (2026)"` | `hello-world-2026` | +| `"Zażółć gęślą"` | `zazolc-gesla` | +| `"Café Crème"` | `cafe-creme` | +| `"Straße"` | `strasse` | + +Lowercase, Unicode NFD, diacritics stripped, a handful of stroked and ligature +letters transliterated (`ł`, `ø`, `æ`, `œ`, `ß`, `đ`, `þ`), every run of +anything else collapsed to a single dash, then trimmed and truncated. + +The same function is exported, so you can preview a slug in your own UI: + +```ts +import { slugify } from "@vitnode/core/content"; + +slugify("Hello World"); // "hello-world" +slugify("Hello World", 7); // "hello-w" +``` + +It is deterministic: the same input always produces the same slug, on every +machine and in every process. Nothing random and nothing numeric is ever +appended. + + + CJK, Cyrillic and emoji fold to nothing, and the engine refuses to invent a + replacement: + +```text +POST { "title": "日本語のタイトル" } +400 Could not derive "slug" from "title". Send "slug" explicitly. +``` + +Send the slug yourself and it is accepted, normalised like any other. A random +or numeric fallback would produce an unmemorable URL nobody asked for, and would +not be reproducible. + + + +## Stability: the whole point + +**Updating the source field never changes an existing slug.** + +```ts +await articles.update(7, { title: "A completely different title" }); +// slug: still "hello-world" +``` + +A published URL that moves because somebody fixed a typo is a 404 for every +link to it, and there is no redirect history to catch it. So the slug changes +only when the update payload names it: + +```ts +await articles.update(7, { slug: " A Brand New Slug! " }); +// slug: "a-brand-new-slug" - normalised, but changed because you asked +``` + +Re-sending the stored value in a different shape counts as no change at all: +normalisation runs before the diff, so `"Hello World"` and `hello-world` are the +same stored value and no write happens. + + + There is no slug history and no automatic redirect. The old URL starts + answering 404 the moment the new one takes effect. If that matters, keep a + redirect table of your own - and see + [Limitations](/docs/dev/content-engine/limitations). + + +## Collisions + +Two rows cannot share a slug. The unique index is what enforces it, and nothing +auto-suffixes: + +```text +POST { "title": "Hello World" } → slug "hello-world", 201 +POST { "title": "Hello World" } → 409 +POST { "slug": "hello-world" } → 409 +``` + +A derived collision and an explicit one are the same 409, because they are the +same problem: the URL is taken. Silently storing `hello-world-2` would hand back +a URL the author never chose and cannot predict. + +## Migrations + +On a **new** table the column and its index arrive in one statement, and Drizzle +Kit generates it correctly. + +On a **populated** table it does not. `ADD COLUMN … NOT NULL` with no default +fails outright, so the generated one-liner has to be split by hand: + +```sql +ALTER TABLE "example_articles" ADD COLUMN "slug" varchar(160);--> statement-breakpoint + +-- The subset of `slugify` SQL can do without an extension. +UPDATE "example_articles" +SET "slug" = NULLIF( + trim(both '-' from regexp_replace(lower(left("title", 160)), '[^a-z0-9]+', '-', 'g')), + '' +);--> statement-breakpoint + +-- Every row gets its id appended. The base is truncated *first*, to leave +-- room for "-" and the id. +UPDATE "example_articles" AS a +SET "slug" = concat_ws( + '-', + NULLIF( + trim( + both '-' from + left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text)) + ), + '' + ), + a."id" +);--> statement-breakpoint + +ALTER TABLE "example_articles" ALTER COLUMN "slug" SET NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "example_articles_slug_key" ON "example_articles" USING btree ("slug"); +``` + +Existing rows come out looking like this: + +```text +getting-started-42 +5 +``` + +### Why every row, and not just the duplicates + +Suffixing only the ambiguous rows is the obvious version, and it is wrong. The +rescue value is *itself* a slug, so it can land on a natural one that was left +alone: + +```text +id=1 "Foo 2" → "foo-2" unique, left alone +id=2 "Foo" → "foo" → "foo-2" rescued... onto row 1 +id=3 "Foo" → "foo" → "foo-3" +``` + +The same trap catches the fallback: a title that normalises to nothing becomes +its bare id, which collides with a row whose title genuinely normalised to that +number. Neither shows up until `CREATE UNIQUE INDEX` fails, two statements later +and halfway through a deploy. + +Appending the id everywhere makes that impossible rather than unlikely. Every +value is `-`, or `` when the title left nothing behind - so two of +them can only be equal if their ids are, and ids are the primary key. There is +nothing left to check, and no loop to write. + + + The first statement can already fill the column: `left("title", 160)` produces + a slug of exactly 160 characters. Appending `-42` to *that* overflows + `varchar(160)` and fails the migration. + +So the base is cut to `160 - 1 - length(id)` first, and the trim runs after the +cut so a mid-word truncation cannot leave a trailing dash. `NULLIF` plus +`concat_ws` drop an empty base entirely, which is how a title in a non-Latin +script becomes just its id rather than `-42`. + + + +Every migrated slug is therefore non-empty, at most 160 characters, free of +leading and trailing dashes, unique, and the same on every machine that runs it. + + + The `-42` is a one-off, deterministic tie-breaker for rows that existed before + the column did. It is **not** what the engine does when content is created or + edited. + +The two rules differ because the jobs differ. A migration has to give *every* +pre-existing row a unique value in one pass, with no author around to ask. At +runtime there is one - and silently changing the URL they chose is worse than +telling them it is taken, so a live collision is a [409](#collisions). + + + +The [example plugin's migration](https://github.com/aXenDeveloper/vitnode/blob/canary/apps/docs/migrations/0024_add_example_article_slug.sql) +is exactly that file. The Postgres integration test seeds both collision +scenarios above, two titles that fill the whole column and a non-Latin one, then +replays the migration over them - so the recipe is checked rather than +remembered. + +## Where a slug can be used + +- **`admin.titleField`** - allowed, but never chosen automatically. A URL + segment reads poorly in a toast or a relation picker. +- **`admin.list.searchableFields`** - allowed when you ask for it, never by + default. +- **`admin.list.orderableFields`** - like any other field: allowlist it or it is + not orderable. +- **`publicApi.fields`** - and a public content type needs + [exactly one](/docs/dev/content-engine/public-api), because it is what the + public detail route resolves by. diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx index be4044388..095f08b22 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -242,6 +242,14 @@ content.example.article.updated content.example.article.deleted ``` +A content type that opts into +[publication](/docs/dev/content-engine/publication) emits two more: + +```text +content.example.article.published +content.example.article.unpublished +``` + They are registered on the global map by the owning plugin with a single `declare module` block, so the names and payloads are as strongly typed as any core event - `changedFields` narrows to that content type's own field names. @@ -257,13 +265,25 @@ core event - `changedFields` narrows to that content type's own field names. "Update only - the fields whose value actually changed. An update that changes nothing emits no event at all.", type: "string[]", }, + publishedAt: { + description: + "Published only - when the row first went live. Never rewritten by a later unpublish/republish.", + type: "Date", + }, }} /> The envelope already carries the actor, the emitting plugin and the timestamp, -so the payloads stay minimal. Events fire only after the database write has -returned: a failed validation, a delete blocked by a foreign key, and a no-op -update all emit nothing. +so the payloads stay minimal. + +These are emitted by the **generated routes**, not by the Content Service. A +route emits exactly one event per successful mutation, after the database write +has returned - a failed validation, a delete blocked by a foreign key, a no-op +update and a no-op publish all emit nothing, and `published`/`unpublished` never +come with an `updated` alongside them. Calling `service.publish()` or any other +service method directly changes the database and emits nothing; that code owns +its own follow-up. See +[Generated events](/docs/dev/content-engine/events#calling-the-service-directly). **Use cases:** reindex the row for search, invalidate a CDN entry, or mirror the change into a plugin-owned projection. See diff --git a/apps/docs/migrations/0023_add_publication_to_example_articles.sql b/apps/docs/migrations/0023_add_publication_to_example_articles.sql new file mode 100644 index 000000000..bdce07b1f --- /dev/null +++ b/apps/docs/migrations/0023_add_publication_to_example_articles.sql @@ -0,0 +1,10 @@ +-- `example.article` moved from a hand-rolled `status` enum + `publishedAt` +-- field to the generated `publication` columns. The column names and types line +-- up, so the data survives - except for `archived`, which the generated status +-- does not have. Those rows become drafts. +UPDATE "example_articles" SET "status" = 'draft' WHERE "status" NOT IN ('draft', 'published');--> statement-breakpoint +ALTER TABLE "example_articles" ALTER COLUMN "status" SET DATA TYPE varchar(32);--> statement-breakpoint +ALTER TABLE "example_articles" ALTER COLUMN "status" SET DEFAULT 'draft';--> statement-breakpoint +CREATE INDEX "example_articles_status_published_at_idx" ON "example_articles" USING btree ("status","publishedAt");--> statement-breakpoint +-- The engine's invariant: a published row always carries a publication date. +UPDATE "example_articles" SET "publishedAt" = "createdAt" WHERE "status" = 'published' AND "publishedAt" IS NULL; diff --git a/apps/docs/migrations/0024_add_example_article_slug.sql b/apps/docs/migrations/0024_add_example_article_slug.sql new file mode 100644 index 000000000..c20f98f7f --- /dev/null +++ b/apps/docs/migrations/0024_add_example_article_slug.sql @@ -0,0 +1,55 @@ +-- `example.article` gained `field.slug({ source: "title" })`. +-- +-- Drizzle Kit generates this as a single `ADD COLUMN "slug" varchar(160) NOT +-- NULL`, which fails on a populated table: there is no default to backfill the +-- existing rows with. So the column arrives nullable, gets a value derived from +-- the title, and only then becomes NOT NULL and unique. That is the recipe for +-- every slug added to a table that already has data. +ALTER TABLE "example_articles" ADD COLUMN "slug" varchar(160);--> statement-breakpoint +-- The same normalisation `slugify` performs, in the subset of it SQL can do +-- without an extension: lowercase, non-alphanumerics to dashes, trimmed. +-- Accented characters simply drop out here rather than transliterating, which +-- is why the next statement exists. +UPDATE "example_articles" +SET "slug" = NULLIF( + trim(both '-' from regexp_replace(lower(left("title", 160)), '[^a-z0-9]+', '-', 'g')), + '' +);--> statement-breakpoint +-- Every row gets its id appended - not just the ambiguous ones. +-- +-- Suffixing only duplicates looks tidier and is wrong: the rescue value is +-- itself a slug, so it can land on a natural one that was left alone. Titles +-- "Foo 2", "Foo", "Foo" normalise to "foo-2", "foo", "foo"; rescuing only the +-- pair produces "foo-2" and "foo-3", and the first of those is already taken by +-- row 1. The same trap catches a title that normalises to nothing and falls +-- back to its id, against a row whose title genuinely normalised to that +-- number. Both only surface as a failure two statements later, when the unique +-- index is created. +-- +-- Appending the id everywhere makes a collision impossible rather than +-- unlikely: every value ends in `-`, or is `` when the title left +-- nothing behind, and ids are unique. Two results can only be equal if their +-- ids are, so there is nothing left to check. +-- +-- The base is truncated *first*, to leave exactly enough room for "-" and the +-- id: a 160-character slug plus a suffix would overflow varchar(160). The trim +-- runs after the truncation, so cutting mid-word cannot leave a trailing dash. +-- `NULLIF` + `concat_ws` drop an empty base entirely, so a row with no usable +-- title becomes just its id rather than "-12". +-- +-- This is backfill behaviour, and it stops here. A slug chosen at runtime is +-- never silently suffixed; a collision there is a 409. +UPDATE "example_articles" AS a +SET "slug" = concat_ws( + '-', + NULLIF( + trim( + both '-' from + left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text)) + ), + '' + ), + a."id" +);--> statement-breakpoint +ALTER TABLE "example_articles" ALTER COLUMN "slug" SET NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "example_articles_slug_key" ON "example_articles" USING btree ("slug"); diff --git a/apps/docs/migrations/meta/0023_snapshot.json b/apps/docs/migrations/meta/0023_snapshot.json new file mode 100644 index 000000000..17f524d07 --- /dev/null +++ b/apps/docs/migrations/meta/0023_snapshot.json @@ -0,0 +1,2514 @@ +{ + "id": "32678677-ed07-41e5-a08e-d7a6887a0395", + "prevId": "5bd0b1de-db52-41b3-86a7-4f4a9a7ca0b7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/0024_snapshot.json b/apps/docs/migrations/meta/0024_snapshot.json new file mode 100644 index 000000000..91d78207f --- /dev/null +++ b/apps/docs/migrations/meta/0024_snapshot.json @@ -0,0 +1,2535 @@ +{ + "id": "f73d7a47-f42f-4629-8af1-b388c220a427", + "prevId": "32678677-ed07-41e5-a08e-d7a6887a0395", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index b6f14046d..74d9a35a7 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -162,6 +162,20 @@ "when": 1785706178516, "tag": "0022_add_example_content", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1785758740560, + "tag": "0023_add_publication_to_example_articles", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1785764469085, + "tag": "0024_add_example_article_slug", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 83c54e5a9..1e6ffb954 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -91,6 +91,11 @@ "types": "./dist/src/content/server/index.d.ts", "default": "./dist/src/content/server/index.js" }, + "./content/next": { + "import": "./dist/src/content/next/index.js", + "types": "./dist/src/content/next/index.d.ts", + "default": "./dist/src/content/next/index.js" + }, "./api/config": { "import": "./dist/src/api/config.js", "types": "./dist/src/api/config.d.ts", diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx index b8ad44b19..eb6a2e2cd 100644 --- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx +++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx @@ -24,6 +24,7 @@ export const ConfirmActionAlertDialog = ({ children, title, description, + submitVariant, textSubmit, onSubmit, ...props @@ -48,7 +49,11 @@ export const ConfirmActionAlertDialog = ({ }> - + diff --git a/packages/vitnode/src/components/confirm-action/content.tsx b/packages/vitnode/src/components/confirm-action/content.tsx index 85158bace..b3dd88e70 100644 --- a/packages/vitnode/src/components/confirm-action/content.tsx +++ b/packages/vitnode/src/components/confirm-action/content.tsx @@ -10,9 +10,15 @@ import { Button } from "../ui/button"; export const ContentConfirmAction = ({ onSubmit, + submitVariant = "destructive", textSubmit, }: { onSubmit: (props: { onClose: () => void }) => Promise | void; + /** + * Defaults to `destructive`, which is right for the deletes this dialog was + * built for - and wrong for a confirmation that publishes something. + */ + submitVariant?: React.ComponentProps["variant"]; textSubmit?: string; }) => { const t = useTranslations("core.global.confirm_action"); @@ -27,7 +33,7 @@ export const ContentConfirmAction = ({
{t("cancel")} - diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index b86fa1a89..d153202b1 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -42,7 +42,7 @@ export interface ContentFormSpec { } export interface ContentColumnSpec { - kind: "system" | ContentFieldKind; + kind: "publication" | "system" | ContentFieldKind; label: string; name: string; /** Enum value -> translated label, for badge cells. */ @@ -56,9 +56,16 @@ export type ContentFieldLabeller = ( export type ContentEnumLabeller = (name: string, value: string) => string; -const systemKinds: Record = { +/** + * Generated columns have no field descriptor to read a kind from, so they are + * mapped by name. `status` gets its own kind rather than falling into "system", + * which the cell renderer treats as a date. + */ +const systemKinds: Record = { createdAt: "system", id: "system", + publishedAt: "system", + status: "publication", updatedAt: "system", }; @@ -114,6 +121,10 @@ export const buildContentFormSpec = ({ max: fieldValue.max, min: fieldValue.min, }; + case "slug": + // No default and no minimum: an empty slug input means "derive it", + // and the server is what decides whether that is possible. + return { ...base, maxLength: fieldValue.maxLength }; case "text": case "textarea": return { @@ -214,8 +225,14 @@ const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { * Field kinds whose input renders an empty string when it holds no value. Left * as-is, `""` fails ISO-date and identifier validation and the form can never * become valid. + * + * A slug is here for a second reason: an empty box means "derive it from the + * source field", and sending `""` would ask the server to store nothing. */ -const EMPTY_MEANS_UNSET: ReadonlySet = new Set(["dateTime"]); +const EMPTY_MEANS_UNSET: ReadonlySet = new Set([ + "dateTime", + "slug", +]); /** * The combobox needs the whole option to show a label, so an existing diff --git a/packages/vitnode/src/content/boundaries.test.ts b/packages/vitnode/src/content/boundaries.test.ts new file mode 100644 index 000000000..8f0204290 --- /dev/null +++ b/packages/vitnode/src/content/boundaries.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const filesUnder = (directory: string, skip: string[] = []): string[] => { + const entries: string[] = []; + + for (const name of readdirSync(directory)) { + const path = join(directory, name); + if (statSync(path).isDirectory()) { + if (skip.includes(name)) continue; + entries.push(...filesUnder(path, skip)); + continue; + } + if (/\.tsx?$/.test(name)) entries.push(path); + } + + return entries; +}; + +const importsFrom = (path: string): string[] => + [ + ...readFileSync(path, "utf8").matchAll( + /from\s+"([^"]+)"|import\s+"([^"]+)"/g, + ), + ] + .map(match => match[1] ?? match[2]) + .filter(Boolean); + +/** + * The layer rule the whole engine rests on, asserted rather than remembered. + * + * `content/` and `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. Both `next/*` and `server-only` + * throw there, so an accidental import does not fail in CI: it fails when + * somebody runs a migration. + */ +describe("layer boundaries", () => { + // The client-safe core and the server layer only. `content/admin/` is + // deliberately excluded: it is the AdminCP's own layer, it is only ever + // reached from Next, and `fetch.server.ts` there carries `server-only` on + // purpose. `content/next/` is excluded for the same reason. + const engineFiles = [ + ...filesUnder(here, ["admin", "next", "server"]), + ...filesUnder(resolve(here, "server")), + ].filter(path => !/\.test(-d)?\.tsx?$/.test(path)); + + it("has files to check", () => { + // A refactor that moved the engine should fail loudly here rather than + // making this suite vacuously pass. + expect(engineFiles.length).toBeGreaterThan(10); + }); + + it.each(["next/", "server-only"])( + "never imports %s from content/ or content/server/", + prefix => { + const offenders = engineFiles.filter(path => + importsFrom(path).some( + specifier => + specifier === prefix.replace(/\/$/, "") || + specifier.startsWith(prefix), + ), + ); + + expect(offenders.map(path => relative(here, path))).toEqual([]); + }, + ); + + it("keeps the Next-only layer out of the engine's import graph", () => { + const offenders = engineFiles.filter(path => + importsFrom(path).some(specifier => specifier.includes("content/next")), + ); + + expect(offenders.map(path => relative(here, path))).toEqual([]); + }); + + it("is where the Next imports actually live", () => { + // The other half of the rule: `content/next/` exists precisely so those + // imports have somewhere legal to be. + const nextFiles = filesUnder(resolve(here, "next")); + const specifiers = nextFiles.flatMap(importsFrom); + + expect(specifiers).toContain("next/cache"); + expect(specifiers).toContain("server-only"); + }); +}); diff --git a/packages/vitnode/src/content/cache.test.ts b/packages/vitnode/src/content/cache.test.ts new file mode 100644 index 000000000..069242b28 --- /dev/null +++ b/packages/vitnode/src/content/cache.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + contentInvalidationTags, + contentPublicItemTag, + contentPublicListTag, + contentPublicSlugTag, + isContentPubliclyVisible, +} from "./cache"; +import { CONTENT_CACHE_TAG_MAX_LENGTH } from "./const"; + +const ID = "example.article"; + +describe("cache tags", () => { + it("are deterministic and readable", () => { + expect(contentPublicListTag(ID)).toBe("content:example.article:list"); + expect(contentPublicItemTag(ID, 12)).toBe( + "content:example.article:item:12", + ); + expect(contentPublicSlugTag(ID, "hello-world")).toBe( + "content:example.article:slug:hello-world", + ); + }); + + it("gives the same answer every time", () => { + expect(contentPublicSlugTag(ID, "hello")).toBe( + contentPublicSlugTag(ID, "hello"), + ); + }); + + it("isolates one content type from another", () => { + // The content type id is globally unique and already namespaced, so no + // plugin id is needed - but two of them must never collide. + expect(contentPublicListTag("example.article")).not.toBe( + contentPublicListTag("blog.article"), + ); + expect(contentPublicItemTag("example.article", 1)).not.toBe( + contentPublicItemTag("example.category", 1), + ); + }); + + it("keeps the scopes apart", () => { + expect(contentPublicItemTag(ID, 12)).not.toBe( + contentPublicSlugTag(ID, "12"), + ); + }); + + describe("length", () => { + // A slug can be 160 characters and a content type id is unbounded, so the + // 256-character cap Next imposes is reachable. + const long = "a".repeat(400); + + it("stays inside the limit", () => { + expect(contentPublicSlugTag(ID, long).length).toBeLessThanOrEqual( + CONTENT_CACHE_TAG_MAX_LENGTH, + ); + expect(contentPublicListTag("plugin." + long).length).toBeLessThanOrEqual( + CONTENT_CACHE_TAG_MAX_LENGTH, + ); + }); + + it("keeps two long values distinct", () => { + // Plain truncation would collapse these onto one tag, and publishing one + // article would expire another. + expect(contentPublicSlugTag(ID, `${long}-one`)).not.toBe( + contentPublicSlugTag(ID, `${long}-two`), + ); + }); + + it("is still deterministic once clamped", () => { + expect(contentPublicSlugTag(ID, long)).toBe( + contentPublicSlugTag(ID, long), + ); + }); + }); +}); + +describe("isContentPubliclyVisible", () => { + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 60_000); + + it.each([ + ["a published row with a past date", "published", past, true], + ["a draft", "draft", past, false], + ["a published row with no date", "published", null, false], + ["a published row dated in the future", "published", future, false], + ["a row with no publication at all", undefined, undefined, false], + ])("%s", (_name, status, publishedAt, expected) => { + expect(isContentPubliclyVisible({ publishedAt, status })).toBe(expected); + }); + + it("accepts the ISO string a JSON response carries", () => { + expect( + isContentPubliclyVisible({ + publishedAt: past.toISOString(), + status: "published", + }), + ).toBe(true); + }); + + it("refuses an unparseable date rather than assuming", () => { + expect( + isContentPubliclyVisible({ + publishedAt: "not a date", + status: "published", + }), + ).toBe(false); + }); +}); + +describe("the invalidation matrix", () => { + const tags = (input: { + isPublic: boolean; + slugs?: string[]; + wasPublic: boolean; + }) => + contentInvalidationTags({ + contentTypeId: ID, + id: 12, + slugs: ["hello"], + ...input, + }); + + const LIST = "content:example.article:list"; + const ITEM = "content:example.article:item:12"; + const SLUG = "content:example.article:slug:hello"; + + it("creates a draft without touching anything", () => { + expect(tags({ isPublic: false, wasPublic: false })).toEqual([]); + }); + + it("edits a draft without touching anything", () => { + // Nothing public changed, so throwing away a warm cache would be free harm. + expect( + tags({ + isPublic: false, + slugs: ["hello", "hello-again"], + wasPublic: false, + }), + ).toEqual([]); + }); + + it("publishes: list, item and slug", () => { + expect(tags({ isPublic: true, wasPublic: false })).toEqual([ + LIST, + ITEM, + SLUG, + ]); + }); + + it("updates published content: list, item and slug", () => { + expect(tags({ isPublic: true, wasPublic: true })).toEqual([ + LIST, + ITEM, + SLUG, + ]); + }); + + it("updates a changed slug: both the old URL and the new one", () => { + // The old one has to stop resolving and the new one has to start, so both + // are expired in the same pass. + expect( + tags({ isPublic: true, slugs: ["old", "new"], wasPublic: true }), + ).toEqual([ + LIST, + ITEM, + "content:example.article:slug:old", + "content:example.article:slug:new", + ]); + }); + + it("unpublishes: list, item and slug", () => { + expect(tags({ isPublic: false, wasPublic: true })).toEqual([ + LIST, + ITEM, + SLUG, + ]); + }); + + it("deletes something that was public: list, item and slug", () => { + expect(tags({ isPublic: false, wasPublic: true })).toEqual([ + LIST, + ITEM, + SLUG, + ]); + }); + + it("deletes a draft without touching anything", () => { + expect(tags({ isPublic: false, wasPublic: false })).toEqual([]); + }); + + it("never invalidates globally, or across content types", () => { + for (const tag of tags({ isPublic: true, wasPublic: true })) { + expect(tag.startsWith(`content:${ID}:`)).toBe(true); + } + }); + + it("does not repeat a slug that did not change", () => { + expect( + tags({ isPublic: true, slugs: ["hello", "hello"], wasPublic: true }), + ).toEqual([LIST, ITEM, SLUG]); + }); + + it("skips a slug it does not know", () => { + // A content type with no public API has no slug field, so the empty string + // must not become `content:...:slug:`. + expect( + tags({ isPublic: true, slugs: ["", "hello"], wasPublic: true }), + ).toEqual([LIST, ITEM, SLUG]); + }); +}); diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts new file mode 100644 index 000000000..ede4c02ea --- /dev/null +++ b/packages/vitnode/src/content/cache.ts @@ -0,0 +1,106 @@ +import { CONTENT_CACHE_TAG_MAX_LENGTH } from "./const"; +import { clampWithFingerprint } from "./fingerprint"; + +/** + * Cache tags for the generated public API. + * + * Pure strings, no `next/*`, and exported: an app can tag its own `fetch` calls + * and its own `"use cache"` functions with exactly the same values, which is + * the only way its pages get invalidated alongside the generated ones. + * + * 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`. + * + * Next caps a tag at 256 characters and a slug can be 160, so every builder + * runs its result through the same fingerprint clamp the index names use. + * Deterministic, collision-resistant, no new dependency. + */ +const tag = (...parts: (number | string)[]): string => + clampWithFingerprint( + ["content", ...parts.map(String)].join(":"), + CONTENT_CACHE_TAG_MAX_LENGTH, + ); + +/** Every public list page of one content type. */ +export const contentPublicListTag = (contentTypeId: string): string => + tag(contentTypeId, "list"); + +/** One row, by identifier. */ +export const contentPublicItemTag = ( + contentTypeId: string, + id: number, +): string => tag(contentTypeId, "item", id); + +/** One row, by the URL it answers to. */ +export const contentPublicSlugTag = ( + contentTypeId: string, + slug: string, +): string => tag(contentTypeId, "slug", slug); + +export interface ContentInvalidationInput { + contentTypeId: string; + id: number; + /** Whether the row is publicly reachable *after* the mutation. */ + isPublic: boolean; + /** + * Every slug the row answered to across the mutation. On a slug change that + * is two: the old URL has to stop resolving, and the new one has to start. + */ + slugs: readonly string[]; + /** Whether it was publicly reachable *before*. */ + wasPublic: boolean; +} + +/** + * The exact tags one mutation should invalidate - and no others. + * + * Nothing global is ever returned, and one content type's mutation never + * touches another's tags. A row that was private before and is private after + * touches nothing at all: creating a draft, or editing one, changes no public + * response, so invalidating a public list for it would just throw away a warm + * cache for free. + * + * Pure, so the whole matrix is a table test rather than a mocking exercise. + */ +export const contentInvalidationTags = ({ + contentTypeId, + id, + isPublic, + slugs, + wasPublic, +}: ContentInvalidationInput): string[] => { + if (!wasPublic && !isPublic) return []; + + return [ + contentPublicListTag(contentTypeId), + contentPublicItemTag(contentTypeId, id), + ...[...new Set(slugs)] + .filter(slug => slug !== "") + .map(slug => contentPublicSlugTag(contentTypeId, slug)), + ]; +}; + +/** + * Whether a row is reachable through the public API right now. + * + * The JavaScript half of `publishedCondition`, kept in the client-safe layer so + * a server action can answer "was this public?" from a mutation response + * without a second query. Both read the same three clauses; the SQL one is + * still what the database enforces. + */ +export const isContentPubliclyVisible = ({ + publishedAt, + status, +}: { + publishedAt: Date | null | string | undefined; + status: string | undefined; +}): boolean => { + if (status !== "published" || publishedAt === null) return false; + if (publishedAt === undefined) return false; + + const date = + publishedAt instanceof Date ? publishedAt : new Date(publishedAt); + + return !Number.isNaN(date.getTime()) && date.getTime() <= Date.now(); +}; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 05f196bf0..a325d7e30 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -4,6 +4,41 @@ */ export const CONTENT_SYSTEM_FIELDS = ["id", "createdAt", "updatedAt"] as const; +/** + * Columns generated by `publication: { enabled: true }`. + * + * Reserved *only* when publication is enabled. Plenty of Stage 1 content types + * declare their own `status` enum - that stays legal, and opting into + * publication is what turns the name into an error. + */ +export const CONTENT_PUBLICATION_FIELDS = ["status", "publishedAt"] as const; + +export const CONTENT_PUBLICATION_STATUSES = ["draft", "published"] as const; + +const publicationStatuses: ReadonlySet = new Set( + CONTENT_PUBLICATION_STATUSES, +); + +/** + * Whether a value is one of the two generated publication statuses. + * + * The generated Zod schemas already narrow this on the HTTP path, so this is + * defence in depth for the direct-service path: a cast, a plain-JavaScript + * caller or an object built at runtime can put anything in `filters.status`, + * and that value must never reach Drizzle. + * + * Takes `unknown` and returns a predicate derived from the constant rather than + * from `ContentPublicationStatus`, so this module stays free of type imports + * from `types.ts` - which imports from here. + */ +export const isContentPublicationStatus = ( + value: unknown, +): value is (typeof CONTENT_PUBLICATION_STATUSES)[number] => + typeof value === "string" && publicationStatuses.has(value); + +/** `varchar` length of the generated `status` column. */ +export const CONTENT_PUBLICATION_STATUS_LENGTH = 32; + /** * Field kinds a generated equality filter understands. * @@ -18,6 +53,7 @@ export const CONTENT_FILTERABLE_FIELD_KINDS = [ "enum", "number", "relation", + "slug", "text", "user", ] as const; @@ -63,16 +99,90 @@ export const CONTENT_INDEX_NAME_PATTERN = CONTENT_TABLE_NAME_PATTERN; /** Postgres silently truncates identifiers past this length. */ export const CONTENT_IDENTIFIER_MAX_LENGTH = 63; +/** Next rejects a cache tag longer than this. A slug alone can be 160. */ +export const CONTENT_CACHE_TAG_MAX_LENGTH = 256; + export const CONTENT_TEXT_DEFAULT_LENGTH = 255; export const CONTENT_ENUM_DEFAULT_LENGTH = 64; +/** + * `varchar` length of a slug column, and the length {@link slugify} truncates + * to. Shorter than a text field on purpose: a slug is a URL segment, and 160 + * characters is already far past what anyone types or shares. + */ +export const CONTENT_SLUG_DEFAULT_LENGTH = 160; + export const CONTENT_DEFAULT_PAGE_SIZE = 25; export const CONTENT_OPTIONS_LIMIT = 25; -/** Every content type gets these four staff permissions. */ +export const CONTENT_PUBLIC_DEFAULT_PAGE_SIZE = 25; +export const CONTENT_PUBLIC_MAX_PAGE_SIZE = 50; + +/** + * URL segment for a public content type: lowercase, dash separated, one + * segment. No slashes, so a leading or trailing one, an empty segment and `..` + * are all rejected by construction rather than by three more checks. + */ +export const CONTENT_PUBLIC_PATH_PATTERN = /^[a-z][a-z0-9-]*$/; + +export const CONTENT_PUBLIC_PATH_MAX_LENGTH = 64; + +/** + * Path segments a public content type may not claim. + * + * `admin` is the important one: the global admin gate is a `path.includes( + * "/admin/")` substring test, so a public route under that name would demand a + * staff session and never be public at all. + */ +export const CONTENT_PUBLIC_RESERVED_PATHS = ["admin"] as const; + +/** + * Field kinds a public response may carry. + * + * `user` is deliberately absent. A user field resolves to a person, and the + * first public layer should not make leaking one a one-word change - expose an + * author through your own route, with the shape you actually mean. + */ +export const CONTENT_PUBLIC_EXPOSABLE_KINDS = [ + "boolean", + "dateTime", + "enum", + "number", + "relation", + "slug", + "text", + "textarea", +] as const; + +/** + * Generated columns `publicApi.fields` may name. + * + * `status` is missing on purpose: every row the public API returns is + * published, so the column would be a constant. + */ +export const CONTENT_PUBLIC_EXPOSABLE_COLUMNS = [ + "id", + "createdAt", + "updatedAt", + "publishedAt", +] as const; + +/** + * Always available to `orderBy`, with no entry in `publicApi.orderableFields` - + * the same courtesy the admin list extends to its system columns. It is the + * natural order of a public feed, and it leaks nothing that is not already + * implied by the row being published. + */ +export const CONTENT_PUBLIC_ALWAYS_ORDERABLE = "publishedAt"; + +/** + * Every content type gets the first four staff permissions. `can_publish` is + * generated only for content types with `publication: { enabled: true }`. + */ export const CONTENT_PERMISSIONS = { create: "can_create", delete: "can_delete", edit: "can_edit", + publish: "can_publish", view: "can_view", } as const; diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts index 864761e48..8521ab7a2 100644 --- a/packages/vitnode/src/content/define.test.ts +++ b/packages/vitnode/src/content/define.test.ts @@ -266,6 +266,121 @@ describe("defineContentType", () => { }); }); + describe("slug fields", () => { + const defineSlug = (slugField: unknown, extra: object = {}) => + define({ + fields: { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), + body: field.textarea({ nullable: true }), + ...extra, + slug: slugField as ReturnType, + }, + }); + + it("accepts a source that names a text field", () => { + expect( + defineSlug(field.slug({ source: "title" })).fields.slug, + ).toMatchObject({ kind: "slug", nullable: false, source: "title" }); + }); + + it("accepts no source at all", () => { + expect(defineSlug(field.slug()).fields.slug).toMatchObject({ + required: true, + source: undefined, + }); + }); + + it("is optional in the create payload once it has a source", () => { + // The engine can always derive it, so demanding it from the caller would + // be busywork - which is why `field.slug` has no `required` argument. + expect( + defineSlug(field.slug({ source: "title" })).fields.slug, + ).toMatchObject({ required: false }); + }); + + it("rejects a source that does not exist", () => { + expect(() => defineSlug(field.slug({ source: "nope" }))).toThrow( + /sourced from "nope", which is not a field/, + ); + }); + + it.each([ + ["views", "number"], + ["body", "textarea"], + ])("rejects the non-text source %s", source => { + expect(() => defineSlug(field.slug({ source }))).toThrow( + /can only be derived from a text field/, + ); + }); + + it("rejects a source pointing at another slug", () => { + expect(() => + defineSlug(field.slug({ source: "permalink" }), { + permalink: field.slug(), + }), + ).toThrow(/can only be derived from a text field/); + }); + + it("rejects a non-positive maxLength", () => { + expect(() => + defineSlug(field.slug({ maxLength: 0, source: "title" })), + ).toThrow(/must be positive/); + }); + + it("gets a unique index automatically", () => { + expect( + defineSlug(field.slug({ source: "title" })).indexes, + ).toContainEqual({ + name: "test_widgets_slug_key", + on: ["slug"], + unique: true, + }); + }); + + it("is not searchable by default", () => { + expect( + defineSlug(field.slug({ source: "title" })).admin.list.searchableFields, + ).toEqual(["title", "body"]); + }); + + it("can be searched when asked for explicitly", () => { + expect( + define({ + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + }, + admin: { label, list: { searchableFields: ["title", "slug"] } }, + }).admin.list.searchableFields, + ).toEqual(["title", "slug"]); + }); + + it("is never picked as the default title field", () => { + // A URL segment is a poor thing to show in a toast or a relation picker. + expect( + define({ + fields: { + slug: field.slug(), + title: field.text({ required: true }), + }, + }).admin.titleField, + ).toBe("title"); + }); + + it("can be the title field when asked for explicitly", () => { + expect( + define({ + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + }, + admin: { label, titleField: "slug" }, + }).admin.titleField, + ).toBe("slug"); + }); + }); + describe("indexes", () => { it("expands the automatic indexes onto the definition", () => { expect( @@ -351,7 +466,7 @@ describe("defineContentType", () => { }, admin: { label, list: { searchableFields: ["views"] } }, }), - ).toThrow(/not a text or textarea field/); + ).toThrow(/not a text, textarea or slug field/); }); it.each([ diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index ed35a9cbf..39f3232d2 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -4,28 +4,56 @@ import type { ContentFieldMap, ContentFieldsConstraint, ContentIndexInput, + ContentPublicApiConfig, + ContentPublicationConfig, + ContentPublicExposableField, ContentTypeDefinition, ResolvedContentAdminConfig, + ResolvedContentPublicApiConfig, } from "./types"; import { CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FIELD_NAME_PATTERN, + CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_ID_PATTERN, CONTENT_IDENTIFIER_MAX_LENGTH, + CONTENT_PUBLIC_ALWAYS_ORDERABLE, + CONTENT_PUBLIC_EXPOSABLE_COLUMNS, + CONTENT_PUBLIC_EXPOSABLE_KINDS, + CONTENT_PUBLIC_PATH_MAX_LENGTH, + CONTENT_PUBLIC_PATH_PATTERN, + CONTENT_PUBLIC_RESERVED_PATHS, + CONTENT_PUBLICATION_FIELDS, CONTENT_SYSTEM_FIELDS, CONTENT_TABLE_NAME_PATTERN, + isFilterableFieldKind, } from "./const"; import { ContentEngineError } from "./errors"; import { resolveContentIndexes } from "./indexes"; import { buildContentSchemas } from "./schemas"; +/** Kinds the default `searchableFields` picks up, and `titleField` falls back to. */ const SEARCHABLE_KINDS = new Set([ "text", "textarea", ]); +/** + * Kinds an explicit `searchableFields` may name. A slug is searchable when you + * ask for it, but never by default - matching a URL segment against what + * someone typed into a search box is a deliberate choice, not a freebie. + */ +const EXPLICIT_SEARCHABLE_KINDS = new Set([ + ...SEARCHABLE_KINDS, + "slug", +]); + +/** A slug can only be derived from a field that holds a single line of text. */ +const SLUG_SOURCE_KINDS = new Set(["text"]); + const systemFields: readonly string[] = CONTENT_SYSTEM_FIELDS; +const publicationFields: readonly string[] = CONTENT_PUBLICATION_FIELDS; const slugifyModule = (value: string): string => value @@ -40,11 +68,18 @@ const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => { if (fieldValue.kind === "relation" || fieldValue.kind === "user") { return false; } + // A sourced slug has no column default and is not required, but it is always + // writable: the service derives it from the source field. + if (fieldValue.kind === "slug") return fieldValue.source !== undefined; return fieldValue.defaultValue !== undefined; }; -const assertFieldName = (id: string, name: string): void => { +const assertFieldName = ( + id: string, + name: string, + publication: boolean, +): void => { if (systemFields.includes(name)) { throw new ContentEngineError( `"${name}" is a reserved system column and cannot be declared as a field.`, @@ -52,6 +87,13 @@ const assertFieldName = (id: string, name: string): void => { ); } + if (publication && publicationFields.includes(name)) { + throw new ContentEngineError( + `"${name}" is generated by \`publication\` and cannot also be declared as a field. Rename the field, or drop \`publication\` and manage the lifecycle yourself.`, + { contentTypeId: id }, + ); + } + if (!CONTENT_FIELD_NAME_PATTERN.test(name)) { throw new ContentEngineError( `Field "${name}" must be camelCase and start with a lowercase letter.`, @@ -66,6 +108,7 @@ const FIELD_KINDS = new Set([ "enum", "number", "relation", + "slug", "text", "textarea", "user", @@ -130,6 +173,16 @@ const assertField = ( } } + if (fieldValue.kind === "slug") { + const { maxLength } = fieldValue; + if (maxLength !== undefined && maxLength <= 0) { + throw new ContentEngineError( + `Field "${name}" has a maxLength of ${maxLength}; it must be positive.`, + { contentTypeId: id }, + ); + } + } + if (fieldValue.kind === "number") { const { max, min } = fieldValue; if (min !== undefined && max !== undefined && min > max) { @@ -174,6 +227,33 @@ const assertField = ( } }; +/** + * Checks every `field.slug({ source })` against the field map. + * + * Runs after the per-field pass, because a source is a reference to a *sibling* + * field and nothing can see the whole map until then. + */ +const assertSlugSources = (id: string, fields: ContentFieldMap): void => { + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "slug" || fieldValue.source === undefined) continue; + + const source = fields[fieldValue.source]; + if (!source) { + throw new ContentEngineError( + `Slug field "${name}" is sourced from "${fieldValue.source}", which is not a field on this content type.`, + { contentTypeId: id }, + ); + } + + if (!SLUG_SOURCE_KINDS.has(source.kind)) { + throw new ContentEngineError( + `Slug field "${name}" is sourced from "${fieldValue.source}", which is a "${source.kind}" field. A slug can only be derived from a text field.`, + { contentTypeId: id }, + ); + } + } +}; + const assertKnownColumns = ( id: string, label: string, @@ -193,9 +273,13 @@ const resolveAdmin = ( id: string, fields: ContentFieldMap, admin: ContentAdminConfig, + publication: boolean, ): ResolvedContentAdminConfig => { const fieldNames = Object.keys(fields); - const knownColumns = new Set([...fieldNames, ...systemFields]); + const generatedColumns = publication + ? [...systemFields, ...publicationFields] + : systemFields; + const knownColumns = new Set([...fieldNames, ...generatedColumns]); const searchableFields = ( admin.list?.searchableFields?.map(String) ?? @@ -208,11 +292,11 @@ const resolveAdmin = ( new Set(fieldNames), ); const notSearchable = searchableFields.find( - name => !SEARCHABLE_KINDS.has(fields[name].kind), + name => !EXPLICIT_SEARCHABLE_KINDS.has(fields[name].kind), ); if (notSearchable !== undefined) { throw new ContentEngineError( - `admin.list.searchableFields includes "${notSearchable}", which is not a text or textarea field.`, + `admin.list.searchableFields includes "${notSearchable}", which is not a text, textarea or slug field.`, { contentTypeId: id }, ); } @@ -225,9 +309,14 @@ const resolveAdmin = ( new Set(fieldNames), ); - const columns = ( - admin.list?.columns?.map(String) ?? [...fieldNames, "updatedAt"] - ).map(String); + // A published/draft badge is the first thing anyone looks for, so it leads + // the default column list. + const defaultColumns = publication + ? ["status", ...fieldNames, "updatedAt"] + : [...fieldNames, "updatedAt"]; + const columns = (admin.list?.columns?.map(String) ?? defaultColumns).map( + String, + ); assertKnownColumns(id, "admin.list.columns", columns, knownColumns); const formFields = (admin.form?.fields?.map(String) ?? fieldNames).map( @@ -237,7 +326,7 @@ const resolveAdmin = ( const defaultOrderBy = String(admin.list?.defaultOrderBy ?? "updatedAt"); if ( - !systemFields.includes(defaultOrderBy) && + !generatedColumns.includes(defaultOrderBy) && !orderableFields.includes(defaultOrderBy) ) { throw new ContentEngineError( @@ -273,6 +362,197 @@ const resolveAdmin = ( }; }; +const publicExposableKinds: ReadonlySet = new Set( + CONTENT_PUBLIC_EXPOSABLE_KINDS, +); +const publicExposableColumns: readonly string[] = + CONTENT_PUBLIC_EXPOSABLE_COLUMNS; +const publicReservedPaths: readonly string[] = CONTENT_PUBLIC_RESERVED_PATHS; + +const assertPublicPath = (id: string, path: string): void => { + if (!CONTENT_PUBLIC_PATH_PATTERN.test(path)) { + throw new ContentEngineError( + `publicApi.path "${path}" must be one lowercase URL segment: a letter, then letters, digits or dashes. No slashes, no dots, no leading or trailing separator.`, + { contentTypeId: id }, + ); + } + + if (path.length > CONTENT_PUBLIC_PATH_MAX_LENGTH) { + throw new ContentEngineError( + `publicApi.path "${path}" is longer than ${CONTENT_PUBLIC_PATH_MAX_LENGTH} characters.`, + { contentTypeId: id }, + ); + } + + if (publicReservedPaths.includes(path)) { + throw new ContentEngineError( + `publicApi.path "${path}" is reserved. The admin gate matches any request path containing "/admin/", so a public route under that name would demand a staff session.`, + { contentTypeId: id }, + ); + } +}; + +/** + * Checks and fills in `publicApi`. + * + * Every rule here exists to make one guarantee cheap: if a field is not in + * `fields`, nothing public can read it, order by it, search it or filter on it. + * So the subset checks are not tidiness - they are what stops a filter or a + * sort being used to probe a column the response leaves out. + */ +const resolvePublicApi = ( + id: string, + fields: ContentFieldMap, + publicApi: ContentPublicApiConfig | undefined, + publication: boolean, +): ResolvedContentPublicApiConfig => { + if (!publicApi?.enabled) { + return { + defaultOrder: "desc", + defaultOrderBy: CONTENT_PUBLIC_ALWAYS_ORDERABLE, + enabled: false, + fields: [], + filterableFields: [], + orderableFields: [], + path: "", + searchableFields: [], + slugField: "", + }; + } + + if (!publication) { + throw new ContentEngineError( + "publicApi needs `publication: { enabled: true }`. A public API without a draft state would put every row on the internet the moment it is created.", + { contentTypeId: id }, + ); + } + + assertPublicPath(id, publicApi.path); + + const exposed = publicApi.fields.map(String); + if (exposed.length === 0) { + throw new ContentEngineError( + "publicApi.fields is empty. There is no wildcard - list the fields you mean to publish.", + { contentTypeId: id }, + ); + } + + const duplicate = exposed.find( + (name, position) => exposed.indexOf(name) !== position, + ); + if (duplicate !== undefined) { + throw new ContentEngineError( + `publicApi.fields lists "${duplicate}" twice.`, + { contentTypeId: id }, + ); + } + + for (const name of exposed) { + if (publicExposableColumns.includes(name)) continue; + + const fieldValue = fields[name]; + if (!fieldValue) { + if (publicationFields.includes(name)) { + throw new ContentEngineError( + `publicApi.fields includes "${name}", which cannot be exposed. Every row the public API returns is published, so it would be a constant.`, + { contentTypeId: id }, + ); + } + + throw new ContentEngineError( + `publicApi.fields references unknown field "${name}".`, + { contentTypeId: id }, + ); + } + + if (fieldValue.kind === "user") { + throw new ContentEngineError( + `publicApi.fields includes the user field "${name}". User fields are not exposable: publishing a person by listing one word is exactly the accident this rule prevents. Write your own route with the shape you mean.`, + { contentTypeId: id }, + ); + } + + if (!publicExposableKinds.has(fieldValue.kind)) { + throw new ContentEngineError( + `publicApi.fields includes "${name}" of kind "${fieldValue.kind}", which cannot be exposed publicly.`, + { contentTypeId: id }, + ); + } + } + + const exposedSet = new Set(exposed); + const assertExposed = (label: string, names: readonly string[]): void => { + const missing = names.find(name => !exposedSet.has(name)); + if (missing !== undefined) { + throw new ContentEngineError( + `${label} includes "${missing}", which is not in publicApi.fields. A private field must not be reachable through a filter, a sort or a search either.`, + { contentTypeId: id }, + ); + } + }; + + const searchableFields = (publicApi.searchableFields ?? []).map(String); + assertExposed("publicApi.searchableFields", searchableFields); + const notSearchable = searchableFields.find( + name => !EXPLICIT_SEARCHABLE_KINDS.has(fields[name]?.kind), + ); + if (notSearchable !== undefined) { + throw new ContentEngineError( + `publicApi.searchableFields includes "${notSearchable}", which is not a text, textarea or slug field.`, + { contentTypeId: id }, + ); + } + + const filterableFields = (publicApi.filterableFields ?? []).map(String); + assertExposed("publicApi.filterableFields", filterableFields); + const notFilterable = filterableFields.find( + name => !isFilterableFieldKind(fields[name]?.kind ?? ""), + ); + if (notFilterable !== undefined) { + throw new ContentEngineError( + `publicApi.filterableFields includes "${notFilterable}", which is not an equality-filterable field. Filterable kinds: ${CONTENT_FILTERABLE_FIELD_KINDS.join(", ")}.`, + { contentTypeId: id }, + ); + } + + const declaredOrderable = (publicApi.orderableFields ?? []).map(String); + assertExposed("publicApi.orderableFields", declaredOrderable); + const orderableFields = [ + ...new Set([...declaredOrderable, CONTENT_PUBLIC_ALWAYS_ORDERABLE]), + ]; + + const defaultOrderBy = + publicApi.defaultOrderBy ?? CONTENT_PUBLIC_ALWAYS_ORDERABLE; + if (!orderableFields.includes(defaultOrderBy)) { + throw new ContentEngineError( + `publicApi.defaultOrderBy is "${defaultOrderBy}", which is not in publicApi.orderableFields.`, + { contentTypeId: id }, + ); + } + + const slugFields = exposed.filter(name => fields[name]?.kind === "slug"); + if (slugFields.length !== 1) { + throw new ContentEngineError( + slugFields.length === 0 + ? "publicApi.fields must expose exactly one slug field - it is what the public detail route resolves by. Add `field.slug({ source: ... })` and list it." + : `publicApi.fields exposes ${slugFields.length} slug fields (${slugFields.join(", ")}). Expose exactly one, so the detail route has a single identifier.`, + { contentTypeId: id }, + ); + } + + return { + defaultOrder: publicApi.defaultOrder ?? "desc", + defaultOrderBy, + enabled: true, + fields: exposed, + filterableFields, + orderableFields, + path: publicApi.path, + searchableFields, + slugField: slugFields[0], + }; +}; + /** * Declares a content type. The result is plain data - zod and objects only - * so the same definition can be imported by `buildPlugin` (client) and by @@ -281,20 +561,39 @@ const resolveAdmin = ( */ export const defineContentType = < TId extends string, - TFields extends ContentFieldsConstraint, + TFields extends ContentFieldsConstraint, + TPublication extends boolean = false, + TPublicField extends ContentPublicExposableField = never, + TPublicEnabled extends boolean = false, >({ admin, fields, id, indexes = [], + publicApi, + publication, tableName, }: { - admin: ContentAdminConfig; + admin: ContentAdminConfig; fields: TFields; id: TId; - indexes?: ContentIndexInput[]; + indexes?: ContentIndexInput[]; + /** + * Opts into a generated read-only public API. Needs `publication` and exactly + * one exposed slug field. Omit it and nothing public is generated. + */ + publicApi?: + ContentPublicApiConfig | { enabled: TPublicEnabled }; + /** Opts into the draft/published lifecycle. Omit to stay on Stage 1 behaviour. */ + publication?: ContentPublicationConfig | { enabled: TPublication }; tableName: string; -}): ContentTypeDefinition => { +}): ContentTypeDefinition< + TId, + TFields, + TPublication, + TPublicField, + TPublicEnabled +> => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( `Content type id "${id}" must look like "plugin.entity" (lowercase, dot separated).`, @@ -327,13 +626,21 @@ export const defineContentType = < }); } + const publicationEnabled = publication?.enabled === true; + for (const name of fieldNames) { - assertFieldName(id, name); + assertFieldName(id, name, publicationEnabled); assertFieldKind(id, name, fieldMap[name]); assertField(id, name, fieldMap[name]); } - const knownColumns = new Set([...fieldNames, ...systemFields]); + assertSlugSources(id, fieldMap); + + const knownColumns = new Set([ + ...fieldNames, + ...systemFields, + ...(publicationEnabled ? publicationFields : []), + ]); const resolvedIndexes = resolveContentIndexes({ contentTypeId: id, declared: indexes.map(index => { @@ -343,10 +650,11 @@ export const defineContentType = < return { ...index, on }; }), fields: fieldMap, + publication: publicationEnabled, tableName, }); - const resolvedAdmin = resolveAdmin(id, fieldMap, admin); + const resolvedAdmin = resolveAdmin(id, fieldMap, admin, publicationEnabled); const permissionModule = admin.permissionModule ?? slugifyModule(admin.label.plural); @@ -357,15 +665,42 @@ export const defineContentType = < ); } + const resolvedPublicApi = resolvePublicApi( + id, + fieldMap, + // The `{ enabled: TPublicEnabled }` arm of the parameter exists only so an + // `enabled: false` literal still typechecks; `resolvePublicApi` returns the + // disabled config for anything that is not `enabled: true`. + publicApi as ContentPublicApiConfig | undefined, + publicationEnabled, + ); + return { admin: resolvedAdmin, fields, id, indexes: resolvedIndexes, permissionModule, - schemas: buildContentSchemas>({ + publication: { + enabled: publicationEnabled as TPublication, + }, + publicApi: resolvedPublicApi as ResolvedContentPublicApiConfig< + TPublicField, + TPublicEnabled + >, + schemas: buildContentSchemas< + ContentTypeDefinition< + TId, + TFields, + TPublication, + TPublicField, + TPublicEnabled + > + >({ admin: resolvedAdmin, fields: fieldMap, + publicApi: resolvedPublicApi, + publication: publicationEnabled, }), tableName, }; diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index c21815c71..fa056e1e4 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -21,3 +21,23 @@ export class ContentEngineError extends Error { readonly contentTypeId: string | undefined; } + +/** + * A payload the engine understood but cannot write - today, a slug that + * normalises to nothing. + * + * The only per-request member of the family, and the only one whose message is + * meant for the client: it says which field is wrong and what to do about it, + * with nothing internal in it. The generated routes turn it into a 400, where + * every other `ContentEngineError` is a configuration bug and becomes a 500. + */ +export class ContentInputError extends ContentEngineError { + constructor( + message: string, + options?: { cause?: unknown; contentTypeId?: string }, + ) { + super(message, options); + + this.name = "ContentInputError"; + } +} diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts index 08f4ac28a..be4cf2794 100644 --- a/packages/vitnode/src/content/events.ts +++ b/packages/vitnode/src/content/events.ts @@ -1,6 +1,7 @@ import type { ContentFieldName } from "./types"; -export type ContentEventAction = "created" | "deleted" | "updated"; +export type ContentEventAction = + "created" | "deleted" | "published" | "unpublished" | "updated"; export interface ContentCreatedPayload { contentId: number; @@ -15,8 +16,38 @@ export interface ContentUpdatedPayload { contentId: number; } +export interface ContentPublishedPayload { + contentId: number; + /** When the row was published for the *first* time; never rewritten. */ + publishedAt: Date; +} + +export interface ContentUnpublishedPayload { + contentId: number; +} + +/** + * The two extra events a content type with `publication` emits. + * + * They are disjoint from `updated`: `status` and `publishedAt` are generated + * columns, not declared fields, so an `updated` event alongside them would + * carry an empty `changedFields` and lie about what moved. Exactly one event is + * emitted per mutation, and a no-op publish emits nothing at all. + */ +type ContentPublicationEventsFor = + TDefinition extends { publication: { enabled: true } } + ? Record< + `content.${TDefinition["id"]}.published`, + ContentPublishedPayload + > & + Record< + `content.${TDefinition["id"]}.unpublished`, + ContentUnpublishedPayload + > + : Record; + /** - * The three events a content type emits, as a literal-keyed map. + * The events a content type emits, as a literal-keyed map. * * Plugins graft these onto the global event map with one declaration - the * same module-augmentation mechanism every other VitNode event uses: @@ -33,15 +64,14 @@ export interface ContentUpdatedPayload { * names. The envelope already carries the actor, plugin and timestamp, so the * payloads stay minimal. */ -export type ContentEventsFor = Record< - `content.${TDefinition["id"]}.created`, - ContentCreatedPayload -> & - Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> & - Record< - `content.${TDefinition["id"]}.updated`, - ContentUpdatedPayload - >; +export type ContentEventsFor = + ContentPublicationEventsFor & + Record<`content.${TDefinition["id"]}.created`, ContentCreatedPayload> & + Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> & + Record< + `content.${TDefinition["id"]}.updated`, + ContentUpdatedPayload + >; export const contentEventName = < TId extends string, diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index 825b6054b..a749f170c 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -6,6 +6,8 @@ import type { ContentNumberField, ContentOnDelete, ContentRelationField, + ContentSlugField, + ContentSlugRequired, ContentTextareaField, ContentTextField, ContentUserField, @@ -117,6 +119,38 @@ const enumField = < kind: "enum", }); +/** + * A URL segment, normalised on the way in and unique-indexed automatically. + * + * ```ts + * slug: field.slug({ source: "title" }) // derived when the payload omits it + * slug: field.slug() // always supplied by the caller + * ``` + * + * `source` must name a `text` field on the same content type. There is no + * `required` argument: a slug with a source is always derivable and therefore + * optional in the create payload, and one without a source can only come from + * the caller. `nullable` is not an argument either - a row nobody can address + * by URL is not a thing worth allowing. + * + * The slug is never re-derived by an update. Changing the title leaves the URL + * alone; sending `slug` explicitly is the only way to move it. + */ +const slug = ( + args: { + description?: string; + /** `varchar` length and the truncation point. Defaults to 160. */ + maxLength?: number; + source?: TSource; + } = {}, +): ContentSlugField => ({ + ...args, + kind: "slug", + nullable: false, + required: (args.source === undefined) as ContentSlugRequired, + source: args.source as TSource, +}); + const dateTime = < TRequired extends boolean = false, TNullable extends boolean = false, @@ -183,6 +217,7 @@ export const field = { enum: enumField, number, relation, + slug, text, textarea, user, diff --git a/packages/vitnode/src/content/fingerprint.ts b/packages/vitnode/src/content/fingerprint.ts new file mode 100644 index 000000000..72c2e4f25 --- /dev/null +++ b/packages/vitnode/src/content/fingerprint.ts @@ -0,0 +1,35 @@ +/** + * FNV-1a, 32 bits, base36. Deterministic across processes and Node versions, + * needs no dependency, and is short enough to leave a readable prefix intact. + */ +export const fingerprint = (value: string): string => { + let hash = 0x811c9dc5; + + for (let position = 0; position < value.length; position += 1) { + hash ^= value.charCodeAt(position); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return hash.toString(36).padStart(7, "0"); +}; + +/** + * Keeps a generated identifier under a hard length limit. + * + * Plain truncation is not enough: two long values that differ only near the end + * would collapse onto the same result. Appending a fingerprint of the *whole* + * value keeps the prefix readable and the result distinct. + * + * Two callers, two limits: Postgres identifiers cap at 63 characters, and a + * Next cache tag at 256. + */ +export const clampWithFingerprint = ( + value: string, + maxLength: number, +): string => { + if (value.length <= maxLength) return value; + + const suffix = `_${fingerprint(value)}`; + + return `${value.slice(0, maxLength - suffix.length)}${suffix}`; +}; diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 7d5234485..4a88ad4b0 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -1,3 +1,12 @@ +/** + * Universal Content Engine - client-safe surface. + * + * Everything exported here is plain data plus zod: it is safe to import from a + * client component, from `buildPlugin`, and from `src/database/*.ts` (which + * Drizzle Kit executes). Anything that needs Drizzle or Hono lives in + * `@vitnode/core/content/server`, and anything that needs `next/*` in + * `@vitnode/core/content/next`. + */ export { contentEntityKey, contentI18nKeys, @@ -17,35 +26,52 @@ export type { ContentFormFieldSpec, ContentFormSpec, } from "./admin/spec"; -/** - * Universal Content Engine - client-safe surface. - * - * Everything exported here is plain data plus zod: it is safe to import from a - * client component, from `buildPlugin`, and from `src/database/*.ts` (which - * Drizzle Kit executes). Anything that needs Drizzle or Hono lives in - * `@vitnode/core/content/server`. - */ export { + contentInvalidationTags, + contentPublicItemTag, + contentPublicListTag, + contentPublicSlugTag, + isContentPubliclyVisible, +} from "./cache"; +export type { ContentInvalidationInput } from "./cache"; +export { + CONTENT_CACHE_TAG_MAX_LENGTH, CONTENT_DEFAULT_PAGE_SIZE, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS, + CONTENT_PUBLIC_ALWAYS_ORDERABLE, + CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, + CONTENT_PUBLIC_EXPOSABLE_COLUMNS, + CONTENT_PUBLIC_EXPOSABLE_KINDS, + CONTENT_PUBLIC_MAX_PAGE_SIZE, + CONTENT_PUBLIC_PATH_MAX_LENGTH, + CONTENT_PUBLIC_PATH_PATTERN, + CONTENT_PUBLIC_RESERVED_PATHS, + CONTENT_PUBLICATION_FIELDS, + CONTENT_PUBLICATION_STATUS_LENGTH, + CONTENT_PUBLICATION_STATUSES, + CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_SYSTEM_FIELDS, CONTENT_TEXT_DEFAULT_LENGTH, + isContentPublicationStatus, RESERVED_FILTER_KEYS, } from "./const"; export { defineContentType } from "./define"; -export { ContentEngineError } from "./errors"; +export { ContentEngineError, ContentInputError } from "./errors"; export { contentEventName } from "./events"; export type { ContentCreatedPayload, ContentDeletedPayload, ContentEventAction, ContentEventsFor, + ContentPublishedPayload, + ContentUnpublishedPayload, ContentUpdatedPayload, } from "./events"; export { field } from "./fields"; +export { clampWithFingerprint, fingerprint } from "./fingerprint"; export { contentIndexName, toSnakeCase } from "./indexes"; export { contentAdminHref, @@ -54,12 +80,14 @@ export { findContentTypeById, orderableColumns, pathToContentTypeId, + publicOrderableColumns, validateContentTypes, withContentPermissions, } from "./registry"; export type { RegisteredContentType } from "./registry"; export { buildContentSchemas } from "./schemas"; export type { ContentSchemas } from "./schemas"; +export { slugify } from "./slug"; export type { AnyContentTypeDefinition, ContentAdminConfig, @@ -81,10 +109,23 @@ export type { ContentNumberField, ContentOnDelete, ContentOrderableFieldName, + ContentPublicApiConfig, + ContentPublicationConfig, + ContentPublicationField, + ContentPublicationStatus, + ContentPublicExposableField, + ContentPublicFieldName, + ContentPublicFilterInput, + ContentPublicListRow, + ContentPublicOrderableFieldName, + ContentPublicRelation, + ContentPublicSelect, ContentReferenceField, ContentReferenceFieldName, ContentRelationField, ContentSelect, + ContentSlugField, + ContentSlugRequired, ContentSystemField, ContentTextareaField, ContentTextField, @@ -95,4 +136,6 @@ export type { FilterableContentFieldName, ResolvedContentAdminConfig, ResolvedContentIndex, + ResolvedContentPublicApiConfig, + ResolvedContentPublicationConfig, } from "./types"; diff --git a/packages/vitnode/src/content/indexes.test.ts b/packages/vitnode/src/content/indexes.test.ts index a87a3053e..550c6b2d5 100644 --- a/packages/vitnode/src/content/indexes.test.ts +++ b/packages/vitnode/src/content/indexes.test.ts @@ -181,6 +181,53 @@ describe("resolveContentIndexes", () => { ); }); + describe("slug", () => { + const slugFields: ContentFieldMap = { + ...fields, + slug: field.slug({ source: "title" }), + }; + + it("is always unique, with no `unique: true` to remember", () => { + expect(resolve([], slugFields)).toContainEqual({ + name: "test_things_slug_key", + on: ["slug"], + unique: true, + }); + }); + + it("keeps uniqueness when a declared index renames it", () => { + const resolved = resolve( + [{ name: "custom_slug_idx", on: ["slug"] }], + slugFields, + ); + + expect(resolved).toContainEqual({ + name: "custom_slug_idx", + on: ["slug"], + unique: true, + }); + expect(resolved.map(index => index.name)).not.toContain( + "test_things_slug_key", + ); + }); + + it("gives each slug field its own index", () => { + const resolved = resolve([], { + ...slugFields, + permalink: field.slug(), + }); + + expect( + resolved.filter(index => index.unique).map(index => index.name), + ).toEqual( + expect.arrayContaining([ + "test_things_permalink_key", + "test_things_slug_key", + ]), + ); + }); + }); + describe("validation", () => { it("rejects an empty column list", () => { expect(() => resolve([{ on: [] }])).toThrow(/at least one column/); diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index 297edbb19..1f0677dd2 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -10,40 +10,15 @@ import { CONTENT_SYSTEM_FIELDS, } from "./const"; import { ContentEngineError } from "./errors"; +import { clampWithFingerprint } from "./fingerprint"; /** `createdAt` -> `created_at`, matching the SQL identifiers in migrations. */ export const toSnakeCase = (value: string): string => value.replace(/[A-Z]/g, match => `_${match.toLowerCase()}`); -/** - * FNV-1a, 32 bits, base36. Deterministic across processes and Node versions, - * needs no dependency, and is short enough to leave a readable prefix intact. - */ -const fingerprint = (value: string): string => { - let hash = 0x811c9dc5; - - for (let position = 0; position < value.length; position += 1) { - hash ^= value.charCodeAt(position); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - - return hash.toString(36).padStart(7, "0"); -}; - -/** - * Keeps an identifier inside Postgres' 63-character limit. - * - * Plain truncation is not enough: two long tables that differ only in their - * last few characters would collapse onto the same index name. Appending a - * fingerprint of the *full* name keeps the result readable and still distinct. - */ -export const shortenIdentifier = (name: string): string => { - if (name.length <= CONTENT_IDENTIFIER_MAX_LENGTH) return name; - - const suffix = `_${fingerprint(name)}`; - - return `${name.slice(0, CONTENT_IDENTIFIER_MAX_LENGTH - suffix.length)}${suffix}`; -}; +/** Keeps an index name inside Postgres' 63-character identifier limit. */ +export const shortenIdentifier = (name: string): string => + clampWithFingerprint(name, CONTENT_IDENTIFIER_MAX_LENGTH); /** * The deterministic name of a generated index: `
__idx`, or @@ -121,12 +96,15 @@ const named = ( * Expands the declared indexes into the full set the table will carry, then * removes the redundant ones. * - * Four sources feed in, in descending precedence: + * Five sources feed in, in descending precedence: * * 1. `indexes` declared on the content type, - * 2. `field.text({ unique: true })`, + * 2. `field.text({ unique: true })` and every `field.slug()`, which is always + * unique - a slug is a URL, and two rows cannot share one, * 3. every foreign key (`relation` and `user` fields), - * 4. `createdAt` and `updatedAt`, which back the default ordering. + * 4. `createdAt` and `updatedAt`, which back the default ordering, + * 5. `(status, publishedAt)` when publication is enabled - one composite index + * serving both the published predicate and the default public ordering. * * Two entries covering the same columns collapse into one: the first name wins, * and the index is unique if *any* of them asked for uniqueness. So declaring @@ -142,11 +120,13 @@ export const resolveContentIndexes = ({ contentTypeId, declared, fields, + publication = false, tableName, }: { contentTypeId: string; declared: readonly ContentIndexConfig[]; fields: ContentFieldMap; + publication?: boolean; tableName: string; }): ResolvedContentIndex[] => { const seenNames = new Map(); @@ -178,9 +158,12 @@ export const resolveContentIndexes = ({ const candidates: ResolvedContentIndex[] = [ ...declared.map(index => named(tableName, index)), ...fieldEntries - .filter( - ([, fieldValue]) => fieldValue.kind === "text" && fieldValue.unique, - ) + .filter(([, fieldValue]) => { + // A slug is a URL segment, so it is unique whether or not you ask. + if (fieldValue.kind === "slug") return true; + + return fieldValue.kind === "text" && fieldValue.unique === true; + }) .map(([name]) => named(tableName, { on: [name], unique: true })), ...fieldEntries .filter( @@ -191,6 +174,9 @@ export const resolveContentIndexes = ({ ...CONTENT_SYSTEM_FIELDS.filter(name => name !== "id").map(name => named(tableName, { on: [name] }), ), + ...(publication + ? [named(tableName, { on: ["status", "publishedAt"] })] + : []), ]; const bySignature = new Map(); diff --git a/packages/vitnode/src/content/next/fetch.server.test.ts b/packages/vitnode/src/content/next/fetch.server.test.ts new file mode 100644 index 000000000..9ed21da4d --- /dev/null +++ b/packages/vitnode/src/content/next/fetch.server.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testPostContentType } from "@/tests/content-fixtures"; + +interface FetchArgs { + module: string; + options?: { cache?: string; next?: { tags?: string[] } }; + path: string; +} + +const calls = vi.hoisted(() => [] as FetchArgs[]); + +// Throws outside a React Server Component, and this module carries it on +// purpose - see `boundaries.test.ts`. +vi.mock("server-only", () => ({})); + +vi.mock("../../lib/fetcher/raw", () => ({ + rawApiFetch: async (args: FetchArgs) => { + calls.push(args); + + return await Promise.resolve( + new Response(JSON.stringify({ title: "Hello" }), { status: 200 }), + ); + }, +})); + +const { contentPublicFetch } = await import("./fetch.server"); + +const LIST_TAG = "content:test.post:list"; + +const fetchOnce = async (slug?: string) => { + await contentPublicFetch({ + definition: testPostContentType, + pluginId: "@vitnode/example", + slug, + }); + + const call = calls.at(-1); + if (!call) throw new Error("Expected a request."); + + return call; +}; + +beforeEach(() => { + calls.length = 0; +}); + +describe("caching", () => { + it("opts into the persistent Data Cache explicitly", async () => { + // Caching is opt-in in Next 16: without this the response is refetched on + // every request as soon as the route touches a request-time API, and the + // tags below expire something that was never stored. + expect((await fetchOnce()).options?.cache).toBe("force-cache"); + }); + + it("opts in on a detail fetch too", async () => { + expect((await fetchOnce("hello-world")).options?.cache).toBe("force-cache"); + }); +}); + +describe("tags", () => { + it("tags a list fetch with the list tag", async () => { + expect((await fetchOnce()).options?.next?.tags).toEqual([LIST_TAG]); + }); + + it("tags a detail fetch with its own slug tag", async () => { + expect((await fetchOnce("hello-world")).options?.next?.tags).toEqual([ + "content:test.post:slug:hello-world", + ]); + }); + + it("never puts the list tag on a detail fetch", async () => { + // Publishing one post must not throw away every post page. + expect((await fetchOnce("hello-world")).options?.next?.tags).not.toContain( + LIST_TAG, + ); + }); +}); + +describe("path", () => { + it("uses the configured public path", async () => { + const call = await fetchOnce(); + + expect(call.module).toBe("content/posts"); + expect(call.path).toBe("/"); + }); + + it("appends the slug for a detail fetch", async () => { + expect((await fetchOnce("hello-world")).path).toBe("/hello-world"); + }); + + it("encodes a slug that is not URL-safe", async () => { + // A generated slug never looks like this, but the argument is public API + // and may come from anywhere. + expect((await fetchOnce("a b/c?d#e")).path).toBe("/a%20b%2Fc%3Fd%23e"); + }); + + it("leaves the module path alone while encoding the segment", async () => { + // Encoding the whole URL would turn `content/posts` into `content%2Fposts`. + expect((await fetchOnce("a/b")).module).toBe("content/posts"); + }); +}); diff --git a/packages/vitnode/src/content/next/fetch.server.ts b/packages/vitnode/src/content/next/fetch.server.ts new file mode 100644 index 000000000..ac437025d --- /dev/null +++ b/packages/vitnode/src/content/next/fetch.server.ts @@ -0,0 +1,98 @@ +import "server-only"; +import type { z } from "zod"; + +import type { + AnyContentTypeDefinition, + PublicContentTypeDefinition, +} from "../types"; + +import { rawApiFetch } from "../../lib/fetcher/raw"; +import { + contentPublicItemTag, + contentPublicListTag, + contentPublicSlugTag, +} from "../cache"; + +export interface ContentPublicFetchResult { + data?: TData; + status: number; +} + +/** + * Reads the generated public API from a server component, cached and tagged. + * + * Two things happen here that a bare `fetch` would not do: + * + * 1. **`cache: "force-cache"`.** Caching in Next 16 is opt-in - the default + * (`auto no cache`) refetches on every request as soon as the route touches + * a request-time API, and tags on an uncached response expire nothing + * because nothing was stored. Published content is the case that should be + * served from the cache until a mutation says otherwise, so it says so. + * 2. **The tags.** The same strings {@link contentPublicListTag} and friends + * produce, so `revalidateContent` expires exactly these responses - and an + * app that tags its own fetches with them is invalidated at the same moment. + * + * A detail fetch is deliberately **not** tagged with the list tag. Publishing + * one article must not throw away every article page. + * + * Only `200` responses are stored, so a 404 for a draft is never cached and + * publishing it is visible immediately. + */ +export const contentPublicFetch = async ({ + definition, + pluginId, + query, + schema, + slug, +}: { + definition: PublicContentTypeDefinition; + pluginId: string; + query?: Record; + schema?: TSchema; + /** Omit for the list; pass one for the detail route. */ + slug?: string; +}): Promise>> => { + const contentTypeId = definition.id; + const tags = + slug === undefined + ? [contentPublicListTag(contentTypeId)] + : [contentPublicSlugTag(contentTypeId, slug)]; + + const response = await rawApiFetch({ + method: "get", + module: `content/${definition.publicApi.path}`, + options: { + // Opt in explicitly. Relying on the implicit default would make the tags + // below decorative on any route that reads cookies or headers. + cache: "force-cache", + // Next augments the global `RequestInit` with `next`, which is why this + // passes straight through the shared fetcher's `options`. + next: { tags }, + }, + // A generated slug is URL-safe, but this is public API and the argument may + // come from anywhere. Only the segment is encoded - encoding the module + // path would turn its separators into `%2F`. + path: slug === undefined ? "/" : `/${encodeURIComponent(slug)}`, + pluginId, + query, + }); + + if (!response.ok) return { status: response.status }; + + const payload: unknown = await response.json(); + if (!schema) { + return { data: payload as z.infer, status: response.status }; + } + + const parsed = schema.safeParse(payload); + + return parsed.success + ? { data: parsed.data, status: response.status } + : { status: response.status }; +}; + +/** The tag a detail response keyed by identifier should carry. */ +export const contentPublicItemTags = ( + definition: AnyContentTypeDefinition, + id: number, +): string[] => [contentPublicItemTag(definition.id, id)]; diff --git a/packages/vitnode/src/content/next/index.ts b/packages/vitnode/src/content/next/index.ts new file mode 100644 index 000000000..f01f56e7e --- /dev/null +++ b/packages/vitnode/src/content/next/index.ts @@ -0,0 +1,14 @@ +/** + * Universal Content Engine - Next.js surface. + * + * The only place in the engine that imports `next/*`. Everything here carries + * `server-only`, so it can never be reached from a client component - and it + * must never be imported from `content/` or `content/server/`, which + * `apps/api` and drizzle-kit both load in plain Node. + * + * The cache *tags* live in `@vitnode/core/content`, because they are strings. + */ +export { contentPublicFetch, contentPublicItemTags } from "./fetch.server"; +export type { ContentPublicFetchResult } from "./fetch.server"; +export { revalidateContent } from "./revalidate.server"; +export type { ContentInvalidationMode } from "./revalidate.server"; diff --git a/packages/vitnode/src/content/next/revalidate.server.test.ts b/packages/vitnode/src/content/next/revalidate.server.test.ts new file mode 100644 index 000000000..cbf537078 --- /dev/null +++ b/packages/vitnode/src/content/next/revalidate.server.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface CacheCall { + fn: "revalidateTag" | "updateTag"; + profile?: unknown; + tag: string; +} + +const calls = vi.hoisted(() => [] as CacheCall[]); + +vi.mock("server-only", () => ({})); + +vi.mock("next/cache", () => ({ + revalidateTag: (tag: string, profile: unknown) => { + calls.push({ fn: "revalidateTag", profile, tag }); + }, + updateTag: (tag: string) => { + calls.push({ fn: "updateTag", tag }); + }, +})); + +const { revalidateContent } = await import("./revalidate.server"); + +const published = { + contentTypeId: "test.post", + id: 7, + isPublic: true, + slugs: ["hello"], + wasPublic: true, +}; + +const functionsCalled = () => [...new Set(calls.map(call => call.fn))]; + +beforeEach(() => { + calls.length = 0; +}); + +describe("mode", () => { + it("expires immediately by default", () => { + // The default protects the mutations that remove something. A caller that + // knows its response is still safe to serve opts out explicitly. + revalidateContent(published); + + expect(functionsCalled()).toEqual(["updateTag"]); + }); + + it("uses updateTag for `immediate`", () => { + revalidateContent(published, { mode: "immediate" }); + + expect(calls.map(call => call.tag)).toEqual([ + "content:test.post:list", + "content:test.post:item:7", + "content:test.post:slug:hello", + ]); + expect(functionsCalled()).toEqual(["updateTag"]); + }); + + it("uses revalidateTag with the `max` profile for stale-while-revalidate", () => { + revalidateContent(published, { mode: "stale-while-revalidate" }); + + expect(functionsCalled()).toEqual(["revalidateTag"]); + // The two-argument form: without a profile this is the deprecated legacy + // behaviour, which is `updateTag` by another name. + expect(calls.every(call => call.profile === "max")).toBe(true); + }); +}); + +describe("what it touches", () => { + it("calls nothing at all when there are no tags", () => { + revalidateContent({ ...published, isPublic: false, wasPublic: false }); + + expect(calls).toEqual([]); + }); + + it("never reaches another content type", () => { + revalidateContent(published); + + expect(calls.every(call => call.tag.startsWith("content:test.post:"))).toBe( + true, + ); + }); + + it("expires both slugs when a row moved", () => { + revalidateContent({ ...published, slugs: ["old", "new"] }); + + expect(calls.map(call => call.tag)).toContain("content:test.post:slug:old"); + expect(calls.map(call => call.tag)).toContain("content:test.post:slug:new"); + }); +}); diff --git a/packages/vitnode/src/content/next/revalidate.server.ts b/packages/vitnode/src/content/next/revalidate.server.ts new file mode 100644 index 000000000..9ac72f961 --- /dev/null +++ b/packages/vitnode/src/content/next/revalidate.server.ts @@ -0,0 +1,60 @@ +import "server-only"; +import { revalidateTag, updateTag } from "next/cache"; + +import type { ContentInvalidationInput } from "../cache"; + +import { contentInvalidationTags } from "../cache"; + +/** + * How hard a mutation expires the tags it touched. + * + * - `immediate` - `updateTag`. The next request waits for fresh data; no stale + * response is served at all. **Server Actions only**, which is where every + * generated write path already lives. + * - `stale-while-revalidate` - `revalidateTag(tag, "max")`. The cached response + * is served once more while the new one is fetched behind it. Cheaper, and + * callable from a Route Handler. + */ +export type ContentInvalidationMode = "immediate" | "stale-while-revalidate"; + +/** + * Expires the public cache entries one mutation actually affected. + * + * The **only** module in the Content Engine that imports `next/cache`, and the + * reason the tag builders are pure strings a directory up: `content/` and + * `content/server/` are loaded by `apps/api` (a plain `@hono/node-server` + * process) and by drizzle-kit, where `next/cache` throws on import. + * + * Call it from a server action, after the write has returned. Not from the + * service: a service call may be inside a transaction that has not committed, + * may be running outside Next entirely, and has no request scope for the Next + * cache APIs to attach to. A direct caller invalidates for itself, after it + * commits. + * + * `mode` defaults to `immediate`, because the mutations that matter most are + * the ones that *remove* something. Stale-while-revalidate would keep serving + * an unpublished post, a deleted one, or a URL that has moved - for one more + * request each, which is exactly one too many. Pass + * `stale-while-revalidate` for an edit that only changed what a published, + * still-reachable page says; that response is safe to serve once more, and + * keeping the cache warm is worth more than a few seconds of freshness. + * + * @throws if `immediate` is used outside a Server Action - `updateTag` is + * Server-Action-only. From a Route Handler or a webhook, pass + * `stale-while-revalidate`. + */ +export const revalidateContent = ( + input: ContentInvalidationInput, + options?: { mode?: ContentInvalidationMode }, +): void => { + const mode = options?.mode ?? "immediate"; + + for (const tag of contentInvalidationTags(input)) { + if (mode === "immediate") { + updateTag(tag); + continue; + } + + revalidateTag(tag, "max"); + } +}; diff --git a/packages/vitnode/src/content/public.test-d.ts b/packages/vitnode/src/content/public.test-d.ts new file mode 100644 index 000000000..68d6c04a7 --- /dev/null +++ b/packages/vitnode/src/content/public.test-d.ts @@ -0,0 +1,225 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import type { testArticleContentType } from "@/tests/content-fixtures"; + +import { + testCategoryContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import type { ContentPublicService } from "./server/public-service"; +import type { + AnyContentTypeDefinition, + ContentPublicFieldName, + ContentPublicFilterInput, + ContentPublicListRow, + ContentPublicRelation, + ContentPublicSelect, + PublicContentTypeDefinition, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; +import { contentPublicFetch } from "./next/fetch.server"; + +type Post = typeof testPostContentType; +type Article = typeof testArticleContentType; +type Category = typeof testCategoryContentType; + +describe("publicApi types", () => { + // Adding two more type parameters to `ContentTypeDefinition` must not break + // the erased form every relation thunk, registry and route builder uses. + describe("assignability to AnyContentTypeDefinition", () => { + it("holds for a public content type", () => { + expectTypeOf().toExtend(); + assertType(testPostContentType); + }); + + it("still holds for a Stage 1 content type", () => { + expectTypeOf
().toExtend(); + }); + }); + + describe("the enabled flag stays literal", () => { + it("is `true` when opted in", () => { + expectTypeOf(testPostContentType.publicApi.enabled).toEqualTypeOf(); + }); + + it("is `false` when omitted", () => { + const private_ = defineContentType({ + id: "test.private-type", + tableName: "test_private_types", + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Privates", singular: "Private" } }, + }); + + expectTypeOf(private_.publicApi.enabled).toEqualTypeOf(); + }); + }); + + describe("field-name union", () => { + it("is exactly the configured allowlist", () => { + expectTypeOf>().toEqualTypeOf< + "category" | "excerpt" | "publishedAt" | "slug" | "title" + >(); + }); + + it("is empty without a public API", () => { + expectTypeOf>().toEqualTypeOf(); + }); + }); + + describe("projection", () => { + it("has exactly the allowlisted keys", () => { + expectTypeOf>().toEqualTypeOf< + "category" | "excerpt" | "publishedAt" | "slug" | "title" + >(); + }); + + it("omits every private field at compile time", () => { + // The runtime counterpart lives in `public.test.ts` and the public + // service tests - this is the half that stops the leak being written. + expectTypeOf>().not.toHaveProperty("views"); + expectTypeOf>().not.toHaveProperty("author"); + expectTypeOf>().not.toHaveProperty("status"); + expectTypeOf>().not.toHaveProperty("id"); + }); + + it("keeps declared value types", () => { + expectTypeOf< + ContentPublicSelect["title"] + >().toEqualTypeOf(); + expectTypeOf["slug"]>().toEqualTypeOf(); + expectTypeOf["excerpt"]>().toEqualTypeOf< + null | string + >(); + expectTypeOf< + ContentPublicSelect["publishedAt"] + >().toEqualTypeOf(); + }); + + it("projects a relation down to an identifier", () => { + // `category` is required, so it is never null - and it is never the + // related row either. + expectTypeOf< + ContentPublicSelect["category"] + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ id: number }>(); + }); + + it("puts no label on a relation", () => { + // `admin.titleField` is administrative metadata. Reading it through + // somebody else's allowlist is not a decision this projection makes. + expectTypeOf().not.toHaveProperty("label"); + }); + + it("is the same shape for a list row", () => { + expectTypeOf>().toEqualTypeOf< + ContentPublicSelect + >(); + }); + + it("is empty for a content type with no public API", () => { + expectTypeOf< + keyof ContentPublicSelect + >().toEqualTypeOf(); + }); + }); + + describe("filters", () => { + it("accepts an exposed, filterable field", () => { + assertType>({ category: 2 }); + assertType>({ title: "Hello" }); + }); + + it("rejects a private field", () => { + assertType>({ + // @ts-expect-error - `views` is not exposed publicly + views: 10, + }); + assertType>({ + // @ts-expect-error - `author` is not exposed publicly + author: 1, + }); + }); + + it("rejects an exposed field of a non-filterable kind", () => { + assertType>({ + // @ts-expect-error - a textarea has no equality filter + excerpt: "prose", + }); + }); + }); + + describe("the public service", () => { + type Service = ContentPublicService; + + it("reads three ways and writes none", () => { + expectTypeOf().toEqualTypeOf< + "findById" | "findBySlug" | "findMany" + >(); + }); + + it("resolves a single row to the public projection", () => { + // `findById` is direct-plugin API - an event listener holding a + // `contentId` should not have to look a slug up first. No numeric-id + // *route* is generated; the detail URL is the slug. + expectTypeOf< + Awaited> + >().toEqualTypeOf | null>(); + expectTypeOf< + Awaited> + >().toEqualTypeOf | null>(); + }); + + it("takes no predicate argument on either lookup", () => { + // The published condition is applied inside every method. There is no + // parameter a caller could pass to widen it. + expectTypeOf().parameters.toEqualTypeOf<[number]>(); + expectTypeOf().parameters.toEqualTypeOf< + [string] + >(); + }); + + it("lists the public projection too", () => { + expectTypeOf< + Awaited>["edges"] + >().toEqualTypeOf[]>(); + }); + }); + + describe("PublicContentTypeDefinition", () => { + it("is satisfied by a content type with a public API", () => { + expectTypeOf().toExtend(); + }); + + it("is not satisfied without one", () => { + expectTypeOf().not.toExtend(); + expectTypeOf
().not.toExtend(); + }); + + it("is still an AnyContentTypeDefinition", () => { + // Narrowing one flag must not cost the erased form everything else takes. + expectTypeOf().toExtend(); + }); + }); +}); + +describe("contentPublicFetch", () => { + it("accepts a content type with a public API", () => { + void contentPublicFetch({ + definition: testPostContentType, + pluginId: "@vitnode/example", + }); + }); + + it("rejects one without", () => { + // A disabled `publicApi` has an empty `path`, so this would request + // `/api/@vitnode/example/content//` - a compile error, not a runtime one. + void contentPublicFetch({ + // @ts-expect-error - no public API + definition: testCategoryContentType, + pluginId: "@vitnode/example", + }); + }); +}); diff --git a/packages/vitnode/src/content/public.test.ts b/packages/vitnode/src/content/public.test.ts new file mode 100644 index 000000000..3fd1ff72b --- /dev/null +++ b/packages/vitnode/src/content/public.test.ts @@ -0,0 +1,346 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { testPostContentType } from "@/tests/content-fixtures"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +const label = { plural: "Widgets", singular: "Widget" }; + +const baseFields = { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ nullable: true }), + views: field.number({ integer: true, defaultValue: 0 }), + author: field.user(), +}; + +const define = ({ + fields = baseFields, + publication = true, + publicApi, +}: { + fields?: Record; + publicApi?: Record; + publication?: boolean; +} = {}) => + defineContentType({ + id: "test.widget", + tableName: "test_widgets", + fields: fields as typeof baseFields, + ...(publication ? { publication: { enabled: true as const } } : {}), + publicApi: { + enabled: true, + path: "widgets", + fields: ["title", "slug"], + ...publicApi, + } as never, + admin: { label }, + }); + +describe("publicApi", () => { + describe("defaults", () => { + const resolved = define().publicApi; + + it("exposes nothing beyond the allowlist", () => { + expect(resolved.fields).toEqual(["title", "slug"]); + }); + + it("filters and searches nothing until asked", () => { + // Every public capability is opt-in. Reusing the admin allowlists here + // would quietly publish whatever an editor happens to be able to sort by. + expect(resolved.filterableFields).toEqual([]); + expect(resolved.searchableFields).toEqual([]); + }); + + it("always allows ordering by publishedAt", () => { + expect(resolved.orderableFields).toEqual(["publishedAt"]); + expect(resolved.defaultOrderBy).toBe("publishedAt"); + expect(resolved.defaultOrder).toBe("desc"); + }); + + it("records the slug the detail route resolves by", () => { + expect(resolved.slugField).toBe("slug"); + }); + }); + + it("is off, and empty, when the block is omitted", () => { + const private_ = defineContentType({ + id: "test.private", + tableName: "test_privates", + fields: { title: field.text({ required: true }) }, + admin: { label }, + }); + + expect(private_.publicApi.enabled).toBe(false); + expect(private_.publicApi.fields).toEqual([]); + expect(private_.publicApi.path).toBe(""); + }); + + describe("prerequisites", () => { + it("needs publication", () => { + expect(() => define({ publication: false })).toThrow( + /publicApi needs `publication/, + ); + }); + + it("needs an exposed slug field", () => { + expect(() => define({ publicApi: { fields: ["title"] } })).toThrow( + /exactly one slug field/, + ); + }); + + it("refuses two exposed slug fields", () => { + expect(() => + define({ + fields: { ...baseFields, permalink: field.slug({ source: "title" }) }, + publicApi: { fields: ["title", "slug", "permalink"] }, + }), + ).toThrow(/exposes 2 slug fields/); + }); + + it("accepts a second slug field as long as only one is exposed", () => { + expect( + define({ + fields: { ...baseFields, permalink: field.slug({ source: "title" }) }, + publicApi: { fields: ["title", "permalink"] }, + }).publicApi.slugField, + ).toBe("permalink"); + }); + }); + + describe("path", () => { + it.each([ + ["Articles", "uppercase"], + ["my_path", "underscore"], + ["blog/articles", "slash"], + ["/articles", "leading slash"], + ["articles/", "trailing slash"], + ["..", "traversal"], + ["../articles", "traversal"], + ["", "empty"], + ["1articles", "leading digit"], + ])("rejects %j (%s)", path => { + expect(() => define({ publicApi: { path } })).toThrow( + /must be one lowercase URL segment/, + ); + }); + + it("rejects a path past the length limit", () => { + expect(() => define({ publicApi: { path: "a".repeat(65) } })).toThrow( + /longer than 64/, + ); + }); + + it("rejects `admin`, which would trip the admin gate", () => { + // The gate is a `path.includes("/admin/")` substring test, so a public + // route under that name would demand a staff session. + expect(() => define({ publicApi: { path: "admin" } })).toThrow( + /is reserved/, + ); + }); + + it.each(["articles", "knowledge-base", "a"])("accepts %j", path => { + expect(define({ publicApi: { path } }).publicApi.path).toBe(path); + }); + }); + + describe("fields", () => { + it("rejects an unknown name", () => { + expect(() => + define({ publicApi: { fields: ["title", "slug", "nope"] } }), + ).toThrow(/unknown field "nope"/); + }); + + it("rejects a duplicate", () => { + expect(() => + define({ publicApi: { fields: ["title", "slug", "title"] } }), + ).toThrow(/lists "title" twice/); + }); + + it("rejects an empty list", () => { + expect(() => define({ publicApi: { fields: [] } })).toThrow( + /There is no wildcard/, + ); + }); + + it("rejects a user field", () => { + // The one kind that resolves to a person. Publishing one should never be + // a one-word change. + expect(() => + define({ publicApi: { fields: ["title", "slug", "author"] } }), + ).toThrow(/User fields are not exposable/); + }); + + it("rejects `status`, which is a constant publicly", () => { + expect(() => + define({ publicApi: { fields: ["title", "slug", "status"] } }), + ).toThrow(/every row the public API returns is published/i); + }); + + it.each(["id", "createdAt", "updatedAt", "publishedAt"])( + "accepts the generated column %s", + name => { + expect( + define({ publicApi: { fields: ["title", "slug", name] } }).publicApi + .fields, + ).toContain(name); + }, + ); + + it("accepts every exposable declared kind", () => { + const fields = { + ...baseFields, + flag: field.boolean({ defaultValue: false }), + state: field.enum({ defaultValue: "a", values: ["a", "b"] }), + when: field.dateTime({ nullable: true }), + }; + + expect( + define({ + fields, + publicApi: { + fields: [ + "title", + "slug", + "excerpt", + "views", + "flag", + "state", + "when", + ], + }, + }).publicApi.fields, + ).toHaveLength(7); + }); + }); + + describe("subset rules", () => { + // Every one of these exists so a private field cannot be probed sideways. + it.each([ + ["searchableFields", { searchableFields: ["excerpt"] }], + ["filterableFields", { filterableFields: ["views"] }], + ["orderableFields", { orderableFields: ["views"] }], + ])("rejects a private field in %s", (_name, publicApi) => { + expect(() => define({ publicApi })).toThrow( + /which is not in publicApi.fields/, + ); + }); + + it("rejects a searchable field that is not text-like", () => { + expect(() => + define({ + publicApi: { + fields: ["title", "slug", "views"], + searchableFields: ["views"], + }, + }), + ).toThrow(/not a text, textarea or slug field/); + }); + + it("rejects a filterable field of a non-filterable kind", () => { + expect(() => + define({ + publicApi: { + fields: ["title", "slug", "excerpt"], + filterableFields: ["excerpt"], + }, + }), + ).toThrow(/not an equality-filterable field/); + }); + + it("rejects a defaultOrderBy outside the orderable set", () => { + expect(() => define({ publicApi: { defaultOrderBy: "title" } })).toThrow( + /not in publicApi.orderableFields/, + ); + }); + + it("accepts a defaultOrderBy that was made orderable", () => { + expect( + define({ + publicApi: { defaultOrderBy: "title", orderableFields: ["title"] }, + }).publicApi.defaultOrderBy, + ).toBe("title"); + }); + }); + + describe("generated schemas", () => { + const { schemas } = testPostContentType; + + it("projects exactly the allowlisted fields", () => { + expect(Object.keys(schemas.publicSelectObject.shape).sort()).toEqual([ + "category", + "excerpt", + "publishedAt", + "slug", + "title", + ]); + }); + + it("leaves private fields out of the projection", () => { + const keys = Object.keys(schemas.publicSelectObject.shape); + + expect(keys).not.toContain("views"); + expect(keys).not.toContain("author"); + expect(keys).not.toContain("status"); + expect(keys).not.toContain("id"); + }); + + it("projects a relation as an identifier and nothing else", () => { + // Read back through the whole object: `shape[...]` is typed as the base + // Zod interface, which has no `safeParse`. + const row = { + category: { id: 3 }, + excerpt: null, + publishedAt: new Date(), + slug: "hello", + title: "Hello", + }; + + expect(schemas.publicSelectObject.safeParse(row).success).toBe(true); + // Not the related row - one level, one key, no population. + expect( + schemas.publicSelectObject.safeParse({ ...row, category: 3 }).success, + ).toBe(false); + }); + + it("strips a label off a relation rather than publishing it", () => { + // The target's `admin.titleField` is not the public API's to give away. + const parsed = schemas.publicSelectObject.safeParse({ + category: { id: 3, label: "News" }, + excerpt: null, + publishedAt: new Date(), + slug: "hello", + title: "Hello", + }); + + expect(parsed.success).toBe(true); + expect(parsed.data?.category).toEqual({ id: 3 }); + }); + + it("accepts only the configured public filters", () => { + expect(Object.keys(schemas.publicFilters.shape)).toEqual(["category"]); + }); + + it("accepts only the configured public order columns", () => { + expect( + schemas.publicOrder.safeParse({ orderBy: "publishedAt" }).success, + ).toBe(true); + expect(schemas.publicOrder.safeParse({ orderBy: "title" }).success).toBe( + true, + ); + // Private, and not in `orderableFields` either. + expect(schemas.publicOrder.safeParse({ orderBy: "views" }).success).toBe( + false, + ); + }); + + it("takes a slug as the public detail parameter", () => { + expect(schemas.publicParams.parse({ slug: "hello" })).toEqual({ + slug: "hello", + }); + expect(schemas.publicParams.safeParse({ slug: "" }).success).toBe(false); + }); + }); +}); diff --git a/packages/vitnode/src/content/publication.test-d.ts b/packages/vitnode/src/content/publication.test-d.ts new file mode 100644 index 000000000..b4db06a51 --- /dev/null +++ b/packages/vitnode/src/content/publication.test-d.ts @@ -0,0 +1,217 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import type { testCategoryContentType } from "@/tests/content-fixtures"; + +import { + testArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentFilterInput, + ContentOrderableFieldName, + ContentPublicationStatus, + ContentSelect, + ContentUpdateInput, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +type Post = typeof testPostContentType; +type Article = typeof testArticleContentType; +type Category = typeof testCategoryContentType; + +describe("publication", () => { + // The whole Stage 2 type design rests on this: adding type parameters to + // `ContentTypeDefinition` must not break the erased form that every relation + // thunk, registry and route builder is written against. + describe("assignability to AnyContentTypeDefinition", () => { + it("holds for a publication content type", () => { + expectTypeOf().toExtend(); + assertType(testPostContentType); + }); + + it("still holds for a Stage 1 content type", () => { + expectTypeOf
().toExtend(); + assertType(testArticleContentType); + }); + }); + + describe("the enabled flag stays literal", () => { + it("is `true` when opted in", () => { + expectTypeOf( + testPostContentType.publication.enabled, + ).toEqualTypeOf(); + }); + + it("is `false` when omitted", () => { + expectTypeOf( + testArticleContentType.publication.enabled, + ).toEqualTypeOf(); + }); + }); + + describe("select output", () => { + it("gains the two generated columns", () => { + expectTypeOf< + ContentSelect["status"] + >().toEqualTypeOf(); + // Spelled out as well as via the alias: the literal pair is the contract + // the filter schema, the badge and the SQL default all depend on. + expectTypeOf["status"]>().toEqualTypeOf< + "draft" | "published" + >(); + expectTypeOf< + ContentSelect["publishedAt"] + >().toEqualTypeOf(); + }); + + it("leaves a Stage 1 content type's own fields alone", () => { + // Declared as an enum with three values, not the generated two. + expectTypeOf["status"]>().toEqualTypeOf< + "archived" | "draft" | "published" + >(); + }); + + it("adds nothing to a content type without publication", () => { + const plain = defineContentType({ + id: "test.plain", + tableName: "test_plain", + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Plains", singular: "Plain" } }, + }); + + expectTypeOf(plain.publication.enabled).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + "createdAt" | "id" | "title" | "updatedAt" + >(); + }); + }); + + describe("write input", () => { + it("never exposes the generated columns", () => { + expectTypeOf>().not.toHaveProperty("status"); + expectTypeOf>().not.toHaveProperty( + "publishedAt", + ); + expectTypeOf>().not.toHaveProperty("status"); + expectTypeOf>().not.toHaveProperty( + "publishedAt", + ); + + assertType>({ + title: "Hello", + category: 1, + // @ts-expect-error - publishing is not a field update + status: "published", + }); + }); + }); + + describe("reserved field names", () => { + it("rejects `status` and `publishedAt` once publication is enabled", () => { + defineContentType({ + id: "test.clash", + tableName: "test_clash", + fields: { + title: field.text({ required: true }), + // @ts-expect-error - generated by `publication` + status: field.enum({ values: ["a", "b"], defaultValue: "a" }), + }, + publication: { enabled: true }, + admin: { label: { plural: "Clashes", singular: "Clash" } }, + }); + }); + + it("still allows them without publication", () => { + defineContentType({ + id: "test.no-clash", + tableName: "test_no_clash", + fields: { + title: field.text({ required: true }), + status: field.enum({ values: ["a", "b"], defaultValue: "a" }), + publishedAt: field.dateTime({ nullable: true }), + }, + admin: { label: { plural: "Fine", singular: "Fine" } }, + }); + }); + }); + + describe("admin config", () => { + it("accepts the generated columns once enabled", () => { + defineContentType({ + id: "test.columns", + tableName: "test_columns", + fields: { title: field.text({ required: true }) }, + publication: { enabled: true }, + admin: { + label: { plural: "Columns", singular: "Column" }, + list: { + columns: ["status", "title", "publishedAt"], + defaultOrderBy: "publishedAt", + }, + }, + }); + }); + + it("rejects them when publication is off", () => { + defineContentType({ + id: "test.no-columns", + tableName: "test_no_columns", + fields: { title: field.text({ required: true }) }, + admin: { + label: { plural: "Columns", singular: "Column" }, + // @ts-expect-error - `status` is not a column of this content type + list: { columns: ["status", "title"] }, + }, + }); + }); + }); + + // The call-site versions of these live in `server/service.test-d.ts`; these + // pin the type aliases themselves, which is what the generated routes and any + // hand-written query build on. + describe("derived type aliases", () => { + it("adds the generated columns to the orderable union", () => { + expectTypeOf>().toEqualTypeOf< + | "author" + | "category" + | "createdAt" + | "excerpt" + | "id" + | "publishedAt" + | "slug" + | "status" + | "title" + | "updatedAt" + | "views" + >(); + }); + + it("leaves the union alone without publication", () => { + expectTypeOf>().toEqualTypeOf< + "createdAt" | "id" | "title" | "updatedAt" + >(); + }); + + it("adds a status filter typed to the generated pair", () => { + expectTypeOf["status"]>().toEqualTypeOf< + "draft" | "published" | undefined + >(); + }); + + it("adds no status filter without publication", () => { + expectTypeOf>().not.toHaveProperty("status"); + }); + + it("keeps a Stage 1 content type's own status filter literal", () => { + // Declared as a three-value enum, and untouched by publication. + expectTypeOf["status"]>().toEqualTypeOf< + "archived" | "draft" | "published" | undefined + >(); + }); + }); +}); diff --git a/packages/vitnode/src/content/publication.test.ts b/packages/vitnode/src/content/publication.test.ts new file mode 100644 index 000000000..d9ee007de --- /dev/null +++ b/packages/vitnode/src/content/publication.test.ts @@ -0,0 +1,286 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import type { AnyContentTypeDefinition } from "./types"; + +import { CONTENT_PERMISSIONS } from "./const"; +import { defineContentType } from "./define"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; +import { contentPermissionEntries, orderableColumns } from "./registry"; + +// Building definitions through `Partial>` erases the inferred +// field map down to the bare constraint, so the result no longer satisfies +// `AnyContentTypeDefinition`. Real call sites keep their concrete map; this +// widening exists only for the test helper, exactly as in `registry.test.ts`. +const define = ( + overrides: Partial[0]> = {}, +): AnyContentTypeDefinition => + defineContentType({ + id: "test.thing", + tableName: "test_things", + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Things", singular: "Thing" } }, + ...overrides, + }) as AnyContentTypeDefinition; + +describe("publication config", () => { + it("is off unless asked for", () => { + expect(define().publication.enabled).toBe(false); + expect(testArticleContentType.publication.enabled).toBe(false); + }); + + it("is on when opted in", () => { + expect(testPostContentType.publication.enabled).toBe(true); + }); + + describe("reserved field names", () => { + it.each(["status", "publishedAt"])( + "rejects a field called %s once publication is enabled", + name => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + [name]: field.text({ nullable: true }), + }, + publication: { enabled: true }, + }), + ).toThrow(ContentEngineError); + }, + ); + + it("names the fix in the error", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + status: field.text({ nullable: true }), + }, + publication: { enabled: true }, + }), + ).toThrow(/generated by `publication`/); + }); + + // The whole reason reservation is conditional: plenty of Stage 1 content + // types - the example plugin included, until this change - declare their + // own `status`. + it("leaves both names available without publication", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + status: field.enum({ values: ["a", "b"], defaultValue: "a" }), + publishedAt: field.dateTime({ nullable: true }), + }, + }), + ).not.toThrow(); + }); + }); + + describe("admin config", () => { + it("puts the status column first by default", () => { + const definition = define({ publication: { enabled: true } }); + + expect(definition.admin.list.columns).toEqual([ + "status", + "title", + "updatedAt", + ]); + }); + + it("leaves the default columns alone without publication", () => { + expect(define().admin.list.columns).toEqual(["title", "updatedAt"]); + }); + + it("accepts the generated columns in an explicit list", () => { + const definition = define({ + admin: { + label: { plural: "Things", singular: "Thing" }, + list: { columns: ["publishedAt", "title"] }, + }, + publication: { enabled: true }, + }); + + expect(definition.admin.list.columns).toEqual(["publishedAt", "title"]); + }); + + it("rejects them when publication is off", () => { + expect(() => + define({ + admin: { + label: { plural: "Things", singular: "Thing" }, + list: { columns: ["status", "title"] }, + }, + }), + ).toThrow(/unknown field "status"/); + }); + + it("allows ordering by a generated column without an allowlist entry", () => { + const definition = define({ + admin: { + label: { plural: "Things", singular: "Thing" }, + list: { defaultOrderBy: "publishedAt" }, + }, + publication: { enabled: true }, + }); + + expect(definition.admin.list.defaultOrderBy).toBe("publishedAt"); + }); + + it("still rejects an unlisted ordinary field as defaultOrderBy", () => { + expect(() => + define({ + admin: { + label: { plural: "Things", singular: "Thing" }, + list: { defaultOrderBy: "title" }, + }, + publication: { enabled: true }, + }), + ).toThrow(/admin.list.orderableFields/); + }); + }); + + describe("generated indexes", () => { + it("adds one composite index serving the published predicate", () => { + const names = define({ publication: { enabled: true } }).indexes.map( + index => index.name, + ); + + expect(names).toContain("test_things_status_published_at_idx"); + }); + + it("adds nothing without publication", () => { + expect(define().indexes.map(index => index.name)).not.toContain( + "test_things_status_published_at_idx", + ); + }); + + it("collapses a declared index on the same columns", () => { + const indexes = define({ + indexes: [{ name: "things_live", on: ["status", "publishedAt"] }], + publication: { enabled: true }, + }).indexes.filter(index => index.on.join() === "status,publishedAt"); + + expect(indexes).toEqual([ + { name: "things_live", on: ["status", "publishedAt"], unique: false }, + ]); + }); + }); + + describe("generated schemas", () => { + const definition = define({ publication: { enabled: true } }); + + it("returns the two columns in select", () => { + const parsed = definition.schemas.selectObject.parse({ + createdAt: new Date(), + id: 1, + publishedAt: null, + status: "draft", + title: "Hello", + updatedAt: new Date(), + }); + + expect(parsed).toMatchObject({ publishedAt: null, status: "draft" }); + }); + + it("rejects a status outside the generated pair", () => { + expect(() => + definition.schemas.selectObject.parse({ + createdAt: new Date(), + id: 1, + publishedAt: null, + status: "archived", + title: "Hello", + updatedAt: new Date(), + }), + ).toThrow(); + }); + + it.each(["create", "update"] as const)( + "refuses to let %s touch them", + key => { + expect(() => + definition.schemas[key].parse({ status: "published", title: "Hi" }), + ).toThrow(); + }, + ); + + it("filters by status", () => { + expect(definition.schemas.filters.parse({ status: "published" })).toEqual( + { status: "published" }, + ); + }); + + it("has no status filter without publication", () => { + expect(define().schemas.filters.parse({ status: "published" })).toEqual( + {}, + ); + }); + }); + + describe("orderable columns", () => { + it("always allows the generated columns", () => { + expect( + orderableColumns(define({ publication: { enabled: true } })), + ).toEqual(["id", "createdAt", "updatedAt", "status", "publishedAt"]); + }); + + it("does not invent them otherwise", () => { + expect(orderableColumns(define())).toEqual([ + "id", + "createdAt", + "updatedAt", + ]); + }); + }); + + describe("permissions", () => { + const permissions = ( + definition?: Parameters[0], + ) => + contentPermissionEntries(definition).map(entry => + typeof entry === "string" ? entry : entry.permission, + ); + + it("adds can_publish for a publication content type", () => { + expect(permissions(define({ publication: { enabled: true } }))).toEqual([ + "can_view", + "can_create", + "can_edit", + "can_delete", + "can_publish", + ]); + }); + + it("leaves the Stage 1 set untouched otherwise", () => { + expect(permissions(define())).toEqual([ + "can_view", + "can_create", + "can_edit", + "can_delete", + ]); + expect(permissions()).toHaveLength(4); + }); + + it("depends on can_view, like every other generated permission", () => { + const publish = contentPermissionEntries( + define({ publication: { enabled: true } }), + ).find( + entry => + typeof entry !== "string" && + entry.permission === CONTENT_PERMISSIONS.publish, + ); + + expect(publish).toEqual({ + dependsOn: ["can_view"], + permission: "can_publish", + }); + }); + }); +}); diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index f7da4d28e..541df4c8f 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { testArticleContentType, testCategoryContentType, + testPostContentType, } from "@/tests/content-fixtures"; import type { RegisteredContentType } from "./registry"; @@ -17,6 +18,7 @@ import { findContentTypeById, orderableColumns, pathToContentTypeId, + publicOrderableColumns, validateContentTypes, withContentPermissions, } from "./registry"; @@ -299,3 +301,95 @@ describe("orderableColumns", () => { ]); }); }); + +describe("publicOrderableColumns", () => { + it("is the public allowlist, not the admin one", () => { + // The admin list can order by `title` *and* the system columns; the public + // one must not, or an anonymous request could sort by a hidden column. + expect(publicOrderableColumns(testPostContentType)).toEqual([ + "publishedAt", + "title", + ]); + expect(publicOrderableColumns(testPostContentType)).not.toContain( + "createdAt", + ); + }); + + it("is empty for a content type with no public API", () => { + expect(publicOrderableColumns(testArticleContentType)).toEqual([]); + }); +}); + +describe("public paths", () => { + const publicWidget = (id: string, tableName: string, path: string) => + defineContentType({ + id, + tableName, + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + }, + publication: { enabled: true }, + publicApi: { enabled: true, path, fields: ["title", "slug"] }, + admin: { + label: { plural: "Widgets", singular: "Widget" }, + // Distinct, so the permission-module check does not fire first and mask + // the one this block is about. + permissionModule: tableName, + }, + }); + + it("accepts distinct paths", () => { + expect(() => + validateContentTypes([ + entry(publicWidget("test.one", "test_ones", "ones")), + entry(publicWidget("test.two", "test_twos", "twos")), + ]), + ).not.toThrow(); + }); + + it("rejects two content types in one plugin claiming the same path", () => { + expect(() => + validateContentTypes([ + entry(publicWidget("test.one", "test_ones", "things")), + entry(publicWidget("test.two", "test_twos", "things")), + ]), + ).toThrow(/Public path "things" is claimed by both/); + }); + + it("allows two plugins to claim the same path", () => { + // The route is `/api/{pluginId}/content/{path}`, so these do not collide. + // Refusing them would fail an app's boot over a name neither author can + // see, and force one of them to rename a public URL. + expect(() => + validateContentTypes([ + entry(publicWidget("first.one", "first_ones", "articles"), "@acme/one"), + entry( + publicWidget("second.one", "second_ones", "articles"), + "@acme/two", + ), + ]), + ).not.toThrow(); + }); + + it("names both content types", () => { + // Boot-time errors are only useful if they say where to go and what to fix. + expect(() => + validateContentTypes([ + entry(publicWidget("test.one", "test_ones", "things"), "@acme/first"), + entry(publicWidget("test.two", "test_twos", "things"), "@acme/first"), + ]), + ).toThrow( + /@acme\/first -> test\.one.*@acme\/first -> test\.two|@acme\/first -> test\.two.*@acme\/first -> test\.one/, + ); + }); + + it("ignores content types with no public API", () => { + expect(() => + validateContentTypes([ + entry(testArticleContentType), + entry(testCategoryContentType), + ]), + ).not.toThrow(); + }); +}); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index a9f2300d0..a6ea9bfe7 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -7,6 +7,7 @@ import type { AnyContentTypeDefinition } from "./types"; import { CONTENT_PERMISSIONS, + CONTENT_PUBLICATION_FIELDS, CONTENT_SYSTEM_FIELDS, RESERVED_FILTER_KEYS, } from "./const"; @@ -39,6 +40,8 @@ interface IndexOwner { * This is the only place that sees *every* installed content type at once, * which makes it the only place that can catch a schema-wide clash: a duplicate * table name, or two content types resolving to the same Postgres index name. + * Permission modules and public paths are checked per plugin, because the + * plugin id is part of the key each one is addressed by. */ export const validateContentTypes = ( entries: RegisteredContentType[], @@ -46,6 +49,7 @@ export const validateContentTypes = ( const byId = new Map(); const byTable = new Map(); const byPermission = new Map(); + const byPublicPath = new Map(); const byIndexName = new Map(); for (const entry of entries) { @@ -83,6 +87,25 @@ export const validateContentTypes = ( assertFilterKeys(definition); + // Scoped per plugin, like permission modules and for the same reason: the + // route is `/api/{pluginId}/content/{path}`, so the plugin id already + // separates two of them. Two plugins both publishing "articles" is normal + // and works; forbidding it would make an app fail to boot over a name + // neither author can see, and force one of them to rename a public URL. + // Inside one plugin the two really would collide, so that is an error. + if (definition.publicApi.enabled) { + const path = definition.publicApi.path; + const pathKey = `${pluginId}:${path}`; + const duplicatePath = byPublicPath.get(pathKey); + if (duplicatePath) { + throw new ContentEngineError( + `Public path "${path}" is claimed by both ${describe(duplicatePath)} and ${describe(entry)}. Give one of them a different \`publicApi.path\`.`, + { contentTypeId: definition.id }, + ); + } + byPublicPath.set(pathKey, entry); + } + // `resolveContentIndexes` already rejects a collision inside one content // type. Postgres index names are unique per *schema*, though, so two // content types - from one plugin or from two - cannot share one either. @@ -142,11 +165,17 @@ export const contentAdminHref = (id: string): string => `/admin/content/${contentTypeToPath(id)}`; /** - * The four permissions every content type gets. `can_view` gates the list and - * the nav item; the writes depend on it so a role cannot create rows it cannot - * see. + * The permissions every content type gets. `can_view` gates the list and the + * nav item; the writes depend on it so a role cannot create rows it cannot see. + * + * `can_publish` is added only for a content type with publication enabled - + * publishing is the one generated operation that changes what anonymous + * visitors can see, so it is worth its own gate. It depends on `can_view` like + * the rest, which leaves "may publish but not edit" expressible. */ -export const contentPermissionEntries = (): PermissionStaffEntryInput[] => [ +export const contentPermissionEntries = ( + definition?: AnyContentTypeDefinition, +): PermissionStaffEntryInput[] => [ CONTENT_PERMISSIONS.view, { dependsOn: [CONTENT_PERMISSIONS.view], @@ -160,6 +189,14 @@ export const contentPermissionEntries = (): PermissionStaffEntryInput[] => [ dependsOn: [CONTENT_PERMISSIONS.view], permission: CONTENT_PERMISSIONS.delete, }, + ...(definition?.publication.enabled + ? [ + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.publish, + }, + ] + : []), ]; /** @@ -177,16 +214,33 @@ export const withContentPermissions = ( for (const { definition } of entries) { if (admin[definition.permissionModule]) continue; - admin[definition.permissionModule] = contentPermissionEntries(); + admin[definition.permissionModule] = contentPermissionEntries(definition); } return { ...permissionStaff, admin }; }; -/** Column names a generated route may order by. */ +/** + * Column names a generated route may order by. System columns - and the + * publication columns when enabled - are always allowed, so they need no entry + * in `admin.list.orderableFields`. + */ export const orderableColumns = ( definition: AnyContentTypeDefinition, ): string[] => [ ...definition.admin.list.orderableFields, ...CONTENT_SYSTEM_FIELDS, + ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), ]; + +/** + * Column names the *public* list route may order by. + * + * Deliberately not `orderableColumns`: the admin allowlist includes system + * columns and every field an editor may sort by, and reusing it would let an + * anonymous request order by a column the projection does not expose. Already + * resolved at definition time, so this only restates where it lives. + */ +export const publicOrderableColumns = ( + definition: AnyContentTypeDefinition, +): string[] => definition.publicApi.orderableFields; diff --git a/packages/vitnode/src/content/schemas.test.ts b/packages/vitnode/src/content/schemas.test.ts index da23d6bf9..0832b2f06 100644 --- a/packages/vitnode/src/content/schemas.test.ts +++ b/packages/vitnode/src/content/schemas.test.ts @@ -230,4 +230,79 @@ describe("generated schemas", () => { expect(Object.keys(definition.schemas.form.shape)).toEqual(["title"]); }); }); + + describe("slug", () => { + const withSource = defineContentType({ + id: "test.slugged", + tableName: "test_slugged", + fields: { + title: field.text({ required: true }), + slug: field.slug({ maxLength: 20, source: "title" }), + }, + admin: { label: { plural: "Slugged", singular: "Slug" } }, + }).schemas; + + const withoutSource = defineContentType({ + id: "test.manual-slug", + tableName: "test_manual_slugs", + fields: { + title: field.text({ required: true }), + slug: field.slug(), + }, + admin: { label: { plural: "Manuals", singular: "Manual" } }, + }).schemas; + + it("may be omitted from create when it has a source", () => { + expect(withSource.create.safeParse({ title: "Hello" }).success).toBe( + true, + ); + }); + + it("must be present in create when it has no source", () => { + expect(withoutSource.create.safeParse({ title: "Hello" }).success).toBe( + false, + ); + }); + + it("accepts a supplied value that the service will normalise", () => { + expect( + withSource.create.safeParse({ slug: "Hello World", title: "Hello" }) + .success, + ).toBe(true); + }); + + it("rejects an empty string", () => { + expect( + withSource.create.safeParse({ slug: "", title: "Hello" }).success, + ).toBe(false); + }); + + it("rejects a value past the descriptor's maxLength", () => { + expect( + withSource.create.safeParse({ slug: "a".repeat(21), title: "Hello" }) + .success, + ).toBe(false); + }); + + it("is optional in update, like every other field", () => { + expect(withoutSource.update.safeParse({ slug: "moved" }).success).toBe( + true, + ); + }); + + it("appears in the response shape", () => { + expect(Object.keys(withSource.selectObject.shape)).toContain("slug"); + }); + + it("is equality-filterable", () => { + expect(Object.keys(withSource.filters.shape)).toContain("slug"); + expect(withSource.filters.parse({ slug: "hello-world" })).toMatchObject({ + slug: "hello-world", + }); + }); + + it("survives z.toJSONSchema for AutoForm", () => { + expect(() => z.toJSONSchema(withSource.form)).not.toThrow(); + }); + }); }); diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 62aa40aa1..384613b05 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -5,12 +5,34 @@ import type { ContentCreateInput, ContentFieldDescriptor, ContentFieldMap, + ContentPublicSelect, ContentSelect, ContentUpdateInput, ResolvedContentAdminConfig, + ResolvedContentPublicApiConfig, } from "./types"; -import { CONTENT_SYSTEM_FIELDS, isFilterableFieldKind } from "./const"; +import { + CONTENT_PUBLIC_ALWAYS_ORDERABLE, + CONTENT_PUBLICATION_FIELDS, + CONTENT_PUBLICATION_STATUSES, + CONTENT_SLUG_DEFAULT_LENGTH, + CONTENT_SYSTEM_FIELDS, + isFilterableFieldKind, +} from "./const"; + +/** What a content type without `publicApi` carries: nothing exposed at all. */ +const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { + defaultOrder: "desc", + defaultOrderBy: CONTENT_PUBLIC_ALWAYS_ORDERABLE, + enabled: false, + fields: [], + filterableFields: [], + orderableFields: [], + path: "", + searchableFields: [], + slugField: "", +}; export interface ContentSchemas { /** Request body for create. Rejects unknown keys and system columns. */ @@ -30,6 +52,19 @@ export interface ContentSchemas { order: z.ZodObject; /** Path parameters for the detail/update/delete routes. */ params: z.ZodObject<{ id: z.ZodCoercedNumber }>; + /** + * Equality filters the public list route accepts, one per + * `publicApi.filterableFields`. Empty when public exposure is off. + */ + publicFilters: z.ZodObject; + /** `orderBy` allowlist for the public list route, plus direction. */ + publicOrder: z.ZodObject; + /** Path parameters for the public detail route. */ + publicParams: z.ZodObject<{ slug: z.ZodString }>; + /** The public response projection - exactly `publicApi.fields`. */ + publicSelect: z.ZodType>; + /** The same shape, left as a `ZodObject` so routes can compose it. */ + publicSelectObject: z.ZodObject; /** API response shape. */ select: z.ZodType>; /** @@ -85,6 +120,13 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { case "relation": case "user": return referenceSchema(); + case "slug": + // Never empty: the service normalises before writing, and a value that + // folds to nothing is rejected rather than stored. + return textSchema({ + maxLength: fieldValue.maxLength ?? CONTENT_SLUG_DEFAULT_LENGTH, + minLength: 1, + }); case "text": case "textarea": return textSchema(fieldValue); @@ -117,6 +159,7 @@ const applyPresence = ( if ( fieldValue.kind !== "dateTime" && fieldValue.kind !== "relation" && + fieldValue.kind !== "slug" && fieldValue.kind !== "user" && fieldValue.defaultValue !== undefined ) { @@ -200,19 +243,70 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => ); /** - * Takes only the two pieces it needs rather than a whole definition, so + * An exposed relation comes back as an identifier, and nothing else. + * + * No label: the only one available is the target's `admin.titleField`, which is + * administrative metadata and may name a field the target never publishes. See + * `ContentPublicRelation` for the reasoning. + */ +const publicRelationSchema = (): z.ZodObject => + z.object({ id: z.number() }); + +/** + * The public response shape, built from the allowlist and nothing else. + * + * This is also what the public service's `SELECT` map is derived from, so a + * field missing here is a field that never leaves Postgres - not one that is + * fetched and then deleted. + */ +const publicSelectShape = ( + fields: ContentFieldMap, + publicApi: ResolvedContentPublicApiConfig, +): z.ZodRawShape => + Object.fromEntries( + publicApi.fields.map(name => { + if (name === "id") return [name, z.number()]; + if (name === "createdAt" || name === "updatedAt") return [name, z.date()]; + if (name === "publishedAt") return [name, z.date().nullable()]; + + const fieldValue = fields[name]; + if (fieldValue.kind === "relation") { + const relation = publicRelationSchema(); + + return [name, fieldValue.nullable ? relation.nullable() : relation]; + } + + return [name, applyNullable(baseSelectSchema(fieldValue), fieldValue)]; + }), + ); + +/** + * Takes only the pieces it needs rather than a whole definition, so * `defineContentType` can call it before the definition object exists and * without re-widening its field map. */ export const buildContentSchemas = ({ admin, fields, + publicApi = DISABLED_PUBLIC_API, + publication = false, }: { admin: ResolvedContentAdminConfig; fields: ContentFieldMap; + publicApi?: ResolvedContentPublicApiConfig; + publication?: boolean; }): ContentSchemas => { const fieldNames = Object.keys(fields); + // Read-only on the wire: absent from `create` and `update` (both strict), so + // the only way to move them is `service.publish` / `service.unpublish`. + const publicationSelectShape: z.ZodRawShape = publication + ? { + publishedAt: z.date().nullable(), + status: z.enum(CONTENT_PUBLICATION_STATUSES), + } + : {}; + const selectShape: z.ZodRawShape = { id: z.number(), ...Object.fromEntries( @@ -221,6 +315,7 @@ export const buildContentSchemas = ({ applyNullable(baseSelectSchema(fields[name]), fields[name]), ]), ), + ...publicationSelectShape, createdAt: z.date(), updatedAt: z.date(), }; @@ -235,22 +330,63 @@ export const buildContentSchemas = ({ message: "Provide at least one field to update.", }); - const orderable = [...admin.list.orderableFields, ...CONTENT_SYSTEM_FIELDS]; + const orderable = [ + ...admin.list.orderableFields, + ...CONTENT_SYSTEM_FIELDS, + ...(publication ? CONTENT_PUBLICATION_FIELDS : []), + ]; const selectObject = z.object(selectShape); + const publicSelectObject = z.object(publicSelectShape(fields, publicApi)); + const publicFilterable = new Set(publicApi.filterableFields); + // Derived from the same `filterShape`, then narrowed to the configured + // allowlist - so a public filter can never reach a field the admin filter + // schema would not have accepted either. + const publicFilters = z.object( + Object.fromEntries( + Object.entries(filterShape(fields)).filter(([name]) => + publicFilterable.has(name), + ), + ), + ); + return { // The shapes are assembled in a loop, so their Zod types are erased. // Re-attaching the descriptor-derived types here means every consumer - // route handler, service, AdminCP - stays fully typed with no further // casts. `buildContentSchemas` is covered by `schemas.test-d.ts`. create: create as unknown as z.ZodType>, - filters: z.object(filterShape(fields)), + filters: z.object({ + ...filterShape(fields), + ...(publication + ? { status: z.enum(CONTENT_PUBLICATION_STATUSES).optional() } + : {}), + }), form: z.object(inputShape(fields, admin.form.fields)), order: z.object({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(orderable as [string, ...string[]]).optional(), }), params: z.object({ id: z.coerce.number() }), + publicFilters, + publicOrder: z.object({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z + .enum( + (publicApi.orderableFields.length > 0 + ? publicApi.orderableFields + : [CONTENT_PUBLIC_ALWAYS_ORDERABLE]) as [string, ...string[]], + ) + .optional(), + }), + // Loose on purpose: an unknown slug and a malformed one are both a 404, so + // there is nothing for a stricter pattern to buy. The value is a bound + // parameter, never an identifier. + publicParams: z.object({ slug: z.string().min(1) }), + publicSelect: publicSelectObject as unknown as z.ZodType< + ContentPublicSelect + >, + publicSelectObject, select: selectObject as unknown as z.ZodType>, selectObject, update: update as unknown as z.ZodType>, diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index d967f1ae2..cb951e9d8 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -14,6 +14,9 @@ import type { ContentFieldDescriptor } from "../types"; import { CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_PUBLICATION_STATUS_LENGTH, + CONTENT_PUBLICATION_STATUSES, + CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_TEXT_DEFAULT_LENGTH, } from "../const"; import { ContentEngineError } from "../errors"; @@ -34,6 +37,30 @@ export const buildSystemColumns = (): Record => ({ .$onUpdate(() => new Date()), }); +/** + * The two columns `publication: { enabled: true }` adds. + * + * `status` is `varchar` rather than a Postgres enum, matching how `field.enum` + * is already materialised, so adding a status later is not a type migration. It + * carries `DEFAULT 'draft' NOT NULL` so drizzle-kit backfills an existing table + * in a single statement - every pre-existing row becomes a draft. + * + * `published_at` is nullable with no default: it means "first published at", + * and `unpublish` deliberately leaves it alone. + */ +export const buildPublicationColumns = (): Record< + string, + PgColumnBuilderBase +> => ({ + publishedAt: timestamp(), + status: varchar({ + enum: CONTENT_PUBLICATION_STATUSES, + length: CONTENT_PUBLICATION_STATUS_LENGTH, + }) + .notNull() + .default("draft"), +}); + /** * Applies `NOT NULL` and the column default. * @@ -117,6 +144,12 @@ export const buildContentColumn = ({ return nullable ? column : column.notNull(); } + case "slug": + // Always NOT NULL and never defaulted: a row nobody can address by URL + // is not worth allowing, and there is no sensible default URL. + return varchar({ + length: fieldValue.maxLength ?? CONTENT_SLUG_DEFAULT_LENGTH, + }).notNull(); case "text": return withModifiers( varchar({ diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts index 25e84037c..c325f3b2b 100644 --- a/packages/vitnode/src/content/server/emit.ts +++ b/packages/vitnode/src/content/server/emit.ts @@ -5,6 +5,8 @@ import type { ContentCreatedPayload, ContentDeletedPayload, ContentEventAction, + ContentPublishedPayload, + ContentUnpublishedPayload, ContentUpdatedPayload, } from "../events"; import type { AnyContentTypeDefinition } from "../types"; @@ -14,6 +16,8 @@ import { contentEventName } from "../events"; type ContentPayload = | ContentCreatedPayload | ContentDeletedPayload + | ContentPublishedPayload + | ContentUnpublishedPayload | ContentUpdatedPayload; /** diff --git a/packages/vitnode/src/content/server/http-errors.test.ts b/packages/vitnode/src/content/server/http-errors.test.ts index 28745da1e..cc942e0b5 100644 --- a/packages/vitnode/src/content/server/http-errors.test.ts +++ b/packages/vitnode/src/content/server/http-errors.test.ts @@ -2,6 +2,7 @@ import { HTTPException } from "hono/http-exception"; import { describe, expect, it } from "vitest"; +import { ContentEngineError, ContentInputError } from "../errors"; import { withHttpErrors } from "./http-errors"; const pgError = (code: string) => @@ -41,6 +42,16 @@ describe("withHttpErrors", () => { await expect(statusOf(pgError("23503"), "delete")).resolves.toBe(409); }); + it("maps Postgres 18's own RESTRICT code to 409 as well", async () => { + // Postgres 18 reports a refused `ON DELETE RESTRICT` as `23001`, not + // `23503`. Reading only the older code turned every blocked delete on a + // modern server into a 500. + await expect(statusOf(pgError("23001"), "delete")).resolves.toBe(409); + // `23001` only ever comes from an update or delete of a referenced row, so + // it is a conflict whatever the caller was doing - never a 400. + await expect(statusOf(pgError("23001"), "update")).resolves.toBe(409); + }); + it("maps a missing relation on write to 400", async () => { await expect(statusOf(pgError("23503"), "create")).resolves.toBe(400); await expect(statusOf(pgError("23503"), "update")).resolves.toBe(400); @@ -76,6 +87,44 @@ describe("withHttpErrors", () => { } }); + it("maps a rejected slug to 400", async () => { + const empty = new ContentInputError( + 'Could not derive "slug" from "title". Send "slug" explicitly.', + { contentTypeId: "test.post" }, + ); + + await expect(statusOf(empty, "create")).resolves.toBe(400); + }); + + it("keeps the slug message, which is written for the client", async () => { + // Unlike a driver error, this one names the fix and contains nothing + // internal - swallowing it would leave the caller guessing. + try { + await withHttpErrors( + "create", + async () => + await reject( + new ContentInputError('Send "slug" explicitly.', { + contentTypeId: "test.post", + }), + ), + ); + } catch (error) { + expect((error as HTTPException).message).toContain( + 'Send "slug" explicitly.', + ); + } + }); + + it("still sends a plain configuration error to the 500 handler", async () => { + // `ContentEngineError` is a misconfigured plugin, not a bad request. + const misconfigured = new ContentEngineError('Unknown filter "nope".'); + + await expect( + withHttpErrors("create", async () => await reject(misconfigured)), + ).rejects.toBe(misconfigured); + }); + it("rethrows anything it does not recognise, for the 500 handler", async () => { const unknown = new Error("boom"); diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index f1a393797..2f00c4229 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -1,10 +1,13 @@ import { HTTPException } from "hono/http-exception"; import { ZodError } from "zod"; +import { ContentInputError } from "../errors"; + /** Postgres error codes the engine translates into a useful HTTP status. */ const FOREIGN_KEY_VIOLATION = "23503"; const UNIQUE_VIOLATION = "23505"; const NOT_NULL_VIOLATION = "23502"; +const RESTRICT_VIOLATION = "23001"; /** * Digs the Postgres error code out of whatever the driver threw. @@ -42,6 +45,12 @@ export const rethrowAsHttpError = ( throw new HTTPException(400, { message: "Invalid input data." }); } + // Written for the client on purpose - "provide the slug explicitly" is + // useless if it never leaves the server. + if (error instanceof ContentInputError) { + throw new HTTPException(400, { message: error.message }); + } + switch (errorCode(error)) { case FOREIGN_KEY_VIOLATION: throw new HTTPException(action === "delete" ? 409 : 400, { @@ -50,6 +59,10 @@ export const rethrowAsHttpError = ( ? "This record is still referenced by other content." : "A related record does not exist.", }); + case RESTRICT_VIOLATION: + throw new HTTPException(409, { + message: "This record is still referenced by other content.", + }); case NOT_NULL_VIOLATION: throw new HTTPException(400, { message: "A required field is missing." }); case UNIQUE_VIOLATION: diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index cfa76be02..9cdad1903 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -6,13 +6,30 @@ * throws under plain Node, and both `apps/api` and `drizzle-kit` load these * modules in plain Node. */ -export { buildContentColumn, buildSystemColumns } from "./column-builders"; +export { + buildContentColumn, + buildPublicationColumns, + buildSystemColumns, +} from "./column-builders"; export type { ColumnReferenceThunk } from "./column-builders"; export { emitContentEvent } from "./emit"; export { rethrowAsHttpError, withHttpErrors } from "./http-errors"; export { createContentModel } from "./model"; export type { ContentModel } from "./model"; export { buildContentAdminModule } from "./module"; +export { buildContentPublicModule } from "./public-module"; +export { buildContentPublicRoutes } from "./public-routes"; +export { createContentPublicService } from "./public-service"; +export type { + ContentPublicFindManyArgs, + ContentPublicService, +} from "./public-service"; +export { + publicationColumns, + publicationMethods, + publishedCondition, +} from "./publication"; +export type { PublicationColumns } from "./publication"; export { buildFilterCondition, buildOrderColumn, @@ -21,6 +38,8 @@ export { escapeLikePattern, toColumnValues, } from "./query"; +export { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; +export type { ReferenceTarget } from "./references"; export { buildContentRoutes } from "./routes"; export { createContentService } from "./service"; export type { @@ -29,7 +48,10 @@ export type { ContentLabels, ContentListRow, ContentPageInfo, + ContentPublicationMethods, + ContentPublicationResult, ContentService, + ContentServiceBase, ContentServiceOptions, ContentUpdateResult, } from "./service"; @@ -38,6 +60,7 @@ export type { ContentColumnBuilder, ContentColumnBuilders, ContentColumnName, + ContentPublicationColumnBuilders, ContentReferences, ContentSystemColumnBuilders, ContentTable, diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index ee781dfda..ebea0978d 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition } from "../types"; +import type { ContentPublicService } from "./public-service"; import type { ContentService } from "./service"; import type { ContentColumnName, @@ -10,6 +11,7 @@ import type { ContentTableFor, } from "./types"; +import { createContentPublicService } from "./public-service"; import { createContentService } from "./service"; import { contentTableColumns, createContentTable } from "./table"; @@ -17,6 +19,15 @@ export interface ContentModel { /** Column name -> Drizzle column, for filters, ordering and custom queries. */ columns: Record, PgColumn>; definition: TDefinition; + /** + * The read-only public repository, or `undefined` when the content type has + * no `publicApi`. + * + * `undefined` rather than a throwing stub so the check reads naturally in a + * route builder that has no idea which content type it was handed. + */ + publicService: + ((c: Context) => ContentPublicService) | undefined; /** The definition's schemas, re-typed for this concrete content type. */ schemas: ContentSchemas; /** Typed repository bound to the request's database handle. */ @@ -61,6 +72,10 @@ export const createContentModel = < return { columns, definition, + publicService: definition.publicApi.enabled + ? (c: Context) => + createContentPublicService({ c, columns, definition, table }) + : undefined, schemas, service: (c: Context) => createContentService({ diff --git a/packages/vitnode/src/content/server/public-module.ts b/packages/vitnode/src/content/server/public-module.ts new file mode 100644 index 000000000..8fb440573 --- /dev/null +++ b/packages/vitnode/src/content/server/public-module.ts @@ -0,0 +1,60 @@ +import type { BuildModuleReturn } from "../../api/lib/module"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; + +import { buildModule } from "../../api/lib/module"; +import { buildContentPublicRoutes } from "./public-routes"; + +/** + * Builds the generated public module for a plugin's content types. + * + * A **top-level** module, unlike `buildContentAdminModule`, so the paths land + * outside `/admin/` and the global admin gate never sees them: + * + * ```ts + * buildApiPlugin({ + * pluginId: CONFIG_PLUGIN.pluginId, + * modules: [ + * adminModule, + * buildContentPublicModule({ pluginId, contentTypes: [articleContent] }), + * ], + * }); + * ``` + * + * That yields `GET /api/{pluginId}/content/{publicApi.path}/` and `/{slug}`. + * + * Pass every model you like: a content type without `publicApi` is skipped, so + * the two module builders can take the same array. + * + * + * This module deliberately does **not** set `contentTypes`. `buildApiPlugin` + * collects them recursively, and registering a content type twice makes + * `validateContentTypes` throw "Duplicate content type id". Only + * `buildContentAdminModule` registers. + * + */ +export const buildContentPublicModule =

({ + contentTypes, + pluginId, +}: { + contentTypes: ContentModel[]; + pluginId: P; +}): BuildModuleReturn => { + const modules = contentTypes + .filter(model => model.definition.publicApi.enabled) + .map(model => + buildModule({ + pluginId, + name: model.definition.publicApi.path, + routes: buildContentPublicRoutes(model, { pluginId }), + }), + ); + + return buildModule({ + pluginId, + name: "content", + routes: [], + modules, + // No `contentTypes` - see the warning above. + }); +}; diff --git a/packages/vitnode/src/content/server/public-routes.test.ts b/packages/vitnode/src/content/server/public-routes.test.ts new file mode 100644 index 000000000..9372f20de --- /dev/null +++ b/packages/vitnode/src/content/server/public-routes.test.ts @@ -0,0 +1,325 @@ +// @vitest-environment node +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import { + testCategoryContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { buildContentPublicModule } from "./public-module"; +import { buildContentPublicRoutes } from "./public-routes"; + +// Deliberately NOT mocked: `assertStaffPermission` is never reached, because a +// public route installs no permission middleware. If one ever did, these tests +// would fail on a missing admin session rather than quietly passing. +const categories = createContentModel(testCategoryContentType); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); + +const PLUGIN_ID = "@vitnode/example"; + +const publicRow = { + category: { id: 3 }, + excerpt: "Prose", + publishedAt: new Date("2026-08-01T09:00:00.000Z"), + slug: "hello-world", + title: "Hello world", +}; + +const emptyPage = { + edges: [], + pageInfo: { + count: 0, + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + totalCount: 0, + }, +}; + +/** + * Mounts the generated public routes with the public service stubbed. + * + * No session middleware and no admin context: the request arrives exactly as an + * anonymous one would. + */ +const harness = () => { + const service = { + findById: vi.fn(), + findBySlug: vi.fn(), + findMany: vi.fn(), + }; + + vi.spyOn(posts, "publicService", "get").mockReturnValue(() => service); + + const app = new OpenAPIHono(); + for (const { handler, route } of buildContentPublicRoutes(posts, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, service }; +}; + +describe("public list route", () => { + it("answers without any session at all", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue(emptyPage); + + const response = await app.request("/"); + + // The whole point: no staff permission, no admin middleware, no 401. + expect(response.status).toBe(200); + }); + + it("returns edges and pageInfo", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ + ...emptyPage, + edges: [publicRow], + pageInfo: { ...emptyPage.pageInfo, count: 1, totalCount: 1 }, + }); + + const body = (await (await app.request("/")).json()) as { + edges: Record[]; + pageInfo: { totalCount: number }; + }; + + expect(body.pageInfo.totalCount).toBe(1); + expect(Object.keys(body.edges[0]).sort()).toEqual([ + "category", + "excerpt", + "publishedAt", + "slug", + "title", + ]); + }); + + it("passes pagination, search, order and filters to the service", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue(emptyPage); + + await app.request( + "/?first=5&cursor=12&search=hello&order=asc&orderBy=title&category=3", + ); + + expect(service.findMany).toHaveBeenCalledWith({ + filters: { category: 3 }, + orderBy: { column: "title", order: "asc" }, + query: { cursor: "12", first: "5", last: undefined, search: "hello" }, + }); + }); + + it("ignores a query parameter it does not recognise", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue(emptyPage); + + // A stale bookmark or a tracking parameter is not a client error. + const response = await app.request("/?utm_source=newsletter&nope=1"); + + expect(response.status).toBe(200); + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ filters: {} }), + ); + }); + + it("rejects an order column outside the public allowlist", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue(emptyPage); + + // `views` is private; `createdAt` is orderable in the AdminCP but not here. + expect((await app.request("/?orderBy=views")).status).toBe(400); + expect((await app.request("/?orderBy=createdAt")).status).toBe(400); + expect(service.findMany).not.toHaveBeenCalled(); + }); + + it("rejects a malformed filter value", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue(emptyPage); + + expect((await app.request("/?category=banana")).status).toBe(400); + expect(service.findMany).not.toHaveBeenCalled(); + }); + + it("never accepts a private field as a filter", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue(emptyPage); + + await app.request("/?views=10&author=1&status=draft"); + + // Not a 400 - the filter schema simply has no such key, so it cannot reach + // the query builder. Draft-hunting by query string does not work. + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ filters: {} }), + ); + }); +}); + +describe("public detail route", () => { + it("returns a published row", async () => { + const { app, service } = harness(); + service.findBySlug.mockResolvedValue(publicRow); + + const response = await app.request("/hello-world"); + + expect(response.status).toBe(200); + expect(service.findBySlug).toHaveBeenCalledWith("hello-world"); + }); + + it("returns exactly the allowlisted keys", async () => { + const { app, service } = harness(); + service.findBySlug.mockResolvedValue(publicRow); + + const body = (await (await app.request("/hello-world")).json()) as Record< + string, + unknown + >; + + expect(Object.keys(body).sort()).toEqual([ + "category", + "excerpt", + "publishedAt", + "slug", + "title", + ]); + expect(body).not.toHaveProperty("views"); + expect(body).not.toHaveProperty("author"); + expect(body).not.toHaveProperty("status"); + expect(body).not.toHaveProperty("id"); + }); + + it("projects the relation as an identifier and nothing else", async () => { + const { app, service } = harness(); + service.findBySlug.mockResolvedValue(publicRow); + + const body = (await (await app.request("/hello-world")).json()) as { + category: unknown; + }; + + expect(body.category).toEqual({ id: 3 }); + }); + + it("documents the relation without a label", () => { + // The response schema is the contract a generated client is built from, so + // it has to say the same thing the handler does. + const relation = ( + testPostContentType.schemas.publicSelectObject.shape + .category as unknown as { shape: Record } + ).shape; + + expect(Object.keys(relation)).toEqual(["id"]); + }); + + it("is a 404 for a draft, an unpublished row and a typo alike", async () => { + const { app, service } = harness(); + // The service returns `null` for all three, so the route cannot tell them + // apart - and neither can anyone probing for unpublished URLs. + service.findBySlug.mockResolvedValue(null); + + const response = await app.request("/some-draft"); + + expect(response.status).toBe(404); + }); + + it("never answers 403, which would confirm the row exists", async () => { + const { app, service } = harness(); + service.findBySlug.mockResolvedValue(null); + + expect((await app.request("/some-draft")).status).not.toBe(403); + expect((await app.request("/some-draft")).status).not.toBe(401); + }); +}); + +describe("generated surface", () => { + it("builds only GET routes", () => { + const routes = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID }); + + expect(routes.map(item => item.route.method)).toEqual(["get", "get"]); + }); + + it("documents only the two read operations", () => { + const app = new OpenAPIHono(); + for (const { handler, route } of buildContentPublicRoutes(posts, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + const document = app.getOpenAPI31Document({ + info: { title: "test", version: "1" }, + openapi: "3.1.0", + }); + const paths = document.paths ?? {}; + + expect(Object.keys(paths).sort()).toEqual(["/", "/{slug}"]); + for (const operations of Object.values(paths)) { + // No post, put, patch or delete anywhere under the public prefix. + expect(Object.keys(operations ?? {})).toEqual(["get"]); + } + }); + + it("installs no staff-permission guard", () => { + // `buildRoute` always adds `pluginMiddleware`, and adds a second handler + // only when `adminStaffPermission` is set. Exactly one means none. + const routes = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID }); + + for (const { route } of routes) { + // `createRoute` types `middleware` per route config, so read it through + // the shape `buildRoute` actually assembles. + const { middleware } = route as unknown as { middleware: unknown[] }; + + expect(middleware).toHaveLength(1); + } + }); +}); + +describe("buildContentPublicModule", () => { + it("skips a content type with no public API", () => { + const module = buildContentPublicModule({ + contentTypes: [posts, categories], + pluginId: PLUGIN_ID, + }); + + expect(module.modules?.map(item => item.name)).toEqual(["posts"]); + }); + + it("names each sub-module after its public path", () => { + const module = buildContentPublicModule({ + contentTypes: [posts], + pluginId: PLUGIN_ID, + }); + + expect(module.name).toBe("content"); + expect(module.modules?.[0].name).toBe(testPostContentType.publicApi.path); + }); + + it("registers no content types", () => { + // `buildApiPlugin` collects `contentTypes` recursively, so registering them + // here as well would make `validateContentTypes` throw "Duplicate content + // type id". Only `buildContentAdminModule` registers. + const module = buildContentPublicModule({ + contentTypes: [posts, categories], + pluginId: PLUGIN_ID, + }); + + expect(module.contentTypes).toBeUndefined(); + for (const child of module.modules ?? []) { + expect(child.contentTypes).toBeUndefined(); + } + }); + + it("has no `/admin/` anywhere in its paths", () => { + // The global admin gate is a `path.includes("/admin/")` substring test, so + // a public route that landed under one would demand a staff session. + const routes = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID }); + + for (const { route } of routes) { + expect(route.path).not.toContain("admin"); + } + }); +}); diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts new file mode 100644 index 000000000..2e12f751c --- /dev/null +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -0,0 +1,157 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { + AnyContentTypeDefinition, + ContentPublicFilterInput, + ContentPublicOrderableFieldName, +} from "../types"; +import type { ContentModel } from "./model"; +import type { ContentPublicService } from "./public-service"; + +import { buildRoute } from "../../api/lib/route"; +import { + zodPaginationPageInfo, + zodPaginationQuery, +} from "../../api/lib/with-pagination"; +import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const"; +import { ContentEngineError } from "../errors"; +import { publicOrderableColumns } from "../registry"; + +/** + * The two read-only routes one public content type gets. + * + * ```http + * GET /api/{pluginId}/content/{publicApi.path}/ + * GET /api/{pluginId}/content/{publicApi.path}/{slug} + * ``` + * + * No `adminStaffPermission` and no `/admin/` anywhere in the path, which is + * exactly how every other public route in VitNode is public: by omission. The + * global middleware still runs, so `c.get("user")` is populated (possibly + * `null`) and the IP rate limiter still applies. + * + * Only `get` is ever built here. There is no public create, update, delete, + * publish or unpublish, and no flag that would add one. + */ +export const buildContentPublicRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition, schemas } = model; + const label = definition.admin.label; + + const service = (c: Context): ContentPublicService => { + const build = model.publicService; + if (!build) { + throw new ContentEngineError( + "This content type has no public API, so it should not have a public route either.", + { contentTypeId: definition.id }, + ); + } + + return build(c); + }; + + // `orderBy` is a literal enum, so a column outside the public allowlist is a + // 400 at validation time and shows up in the OpenAPI document. The service + // keeps its own allowlist check for callers that did not come through here. + const orderable = publicOrderableColumns(definition) as [string, ...string[]]; + const paginationQuery = zodPaginationQuery.extend({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(orderable).optional(), + search: z.string().optional(), + }); + const listQuery = paginationQuery.extend(schemas.publicFilters.shape); + + const notFound = () => + new HTTPException(404, { + message: `${label.singular} not found.`, + }); + + const list = buildRoute({ + pluginId, + route: { + method: "get", + path: "/", + description: `List published ${label.plural}`, + request: { query: listQuery }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + edges: z.array(schemas.publicSelectObject), + pageInfo: zodPaginationPageInfo, + }), + }, + }, + description: `Up to ${CONTENT_PUBLIC_MAX_PAGE_SIZE} published ${label.plural}`, + }, + 400: { description: "Invalid query parameters" }, + }, + }, + handler: async c => { + // The whole query string goes through both schemas, each reading only the + // keys it owns, and neither is strict - so a stale bookmark or a tracking + // parameter is ignored rather than turned into a 400. `orderBy` is the + // exception: a *present* but unknown column fails validation. + const raw = c.req.query(); + const { cursor, first, last, order, orderBy, search } = + paginationQuery.parse(raw); + const filters = schemas.publicFilters.parse( + raw, + ) as ContentPublicFilterInput; + + const data = await service(c).findMany({ + filters, + // Both narrowings restate what the schemas just proved: `orderBy` came + // out of a literal enum built from `publicApi.orderableFields`, and + // `filters` out of a shape built from `publicApi.filterableFields`. The + // service re-checks both, since the type is not what protects the query. + orderBy: { + column: orderBy as ContentPublicOrderableFieldName, + order, + }, + query: { cursor, first, last, search }, + }); + + return c.json(data, 200); + }, + }); + + const detail = buildRoute({ + pluginId, + route: { + method: "get", + path: "/{slug}", + description: `Get one published ${label.singular} by slug`, + request: { params: schemas.publicParams }, + responses: { + 200: { + content: { + "application/json": { schema: schemas.publicSelectObject }, + }, + description: `${label.singular} found`, + }, + 404: { description: `${label.singular} not found` }, + }, + }, + handler: async c => { + // A draft, an unpublished row, a cleared publication date and a typo are + // all the same 404. A 403 would confirm the record exists, which is the + // one thing a draft URL must not do. + const row = await service(c).findBySlug(c.req.param("slug")); + if (!row) throw notFound(); + + return c.json(row, 200); + }, + }); + + return [list, detail]; +}; diff --git a/packages/vitnode/src/content/server/public-service.test.ts b/packages/vitnode/src/content/server/public-service.test.ts new file mode 100644 index 000000000..0eec81d8c --- /dev/null +++ b/packages/vitnode/src/content/server/public-service.test.ts @@ -0,0 +1,403 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { + testCategoryContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError } from "../errors"; +import { createContentModel } from "./model"; + +const categories = createContentModel(testCategoryContentType); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); + +const dialect = new PgDialect(); + +interface RecordedCall { + arg: unknown; + op: string; +} + +/** The same chainable Drizzle stand-in `service.test.ts` uses. */ +const createDbMock = (results: unknown[][]) => { + const calls: RecordedCall[] = []; + const queue = [...results]; + + const chain = (rows: unknown[]) => { + const record = (op: string, arg: unknown) => { + calls.push({ arg, op }); + + return builder; + }; + + const builder = { + $dynamic: () => builder, + from: (value: unknown) => record("from", value), + leftJoin: (value: unknown) => record("leftJoin", value), + limit: (value: unknown) => record("limit", value), + orderBy: (value: unknown) => record("orderBy", value), + then: async (resolve: (rows: unknown[]) => TResult) => + Promise.resolve(rows).then(resolve), + where: (value: unknown) => record("where", value), + }; + + return builder; + }; + + const c = { + get: (key: string) => + key === "db" + ? { + select: (arg: unknown) => { + calls.push({ arg, op: "select" }); + + return chain(queue.shift() ?? []); + }, + } + : undefined, + } as Context; + + return { c, calls }; +}; + +const opsOf = (calls: RecordedCall[], op: string) => + calls.filter(call => call.op === op).map(call => call.arg); + +/** The compiled SQL of the last `where` the service handed Drizzle. */ +const lastWhere = (calls: RecordedCall[]) => { + const wheres = opsOf(calls, "where"); + const condition = wheres.at(-1); + if (!condition) throw new Error("Expected a where clause."); + + return dialect.sqlToQuery(condition as never); +}; + +const publicService = (results: unknown[][]) => { + const { c, calls } = createDbMock(results); + const service = posts.publicService?.(c); + if (!service) throw new Error("Expected a public service."); + + return { calls, service }; +}; + +const storedRow = { + category: 3, + excerpt: "Prose", + id: 12, + publishedAt: new Date("2026-08-01T09:00:00.000Z"), + slug: "hello-world", + title: "Hello world", +}; + +describe("model.publicService", () => { + it("exists only for a content type with a public API", () => { + expect(posts.publicService).toBeDefined(); + expect(categories.publicService).toBeUndefined(); + }); + + it("has no write methods at all", () => { + const { service } = publicService([]); + + // Not "omitted from a filtered view" - there is nothing to omit, because + // this is a different object from `model.service`. + for (const method of [ + "create", + "update", + "delete", + "publish", + "unpublish", + "options", + ]) { + expect(service).not.toHaveProperty(method); + } + + expect(Object.keys(service).sort()).toEqual([ + "findById", + "findBySlug", + "findMany", + ]); + }); +}); + +describe("the published invariant", () => { + const PREDICATE = + '"test_posts"."status" = $1 and "test_posts"."publishedAt" is not null and "test_posts"."publishedAt" <= now()'; + + it("is applied by findBySlug", async () => { + const { calls, service } = publicService([[storedRow]]); + + await service.findBySlug("hello-world"); + + const { params, sql } = lastWhere(calls); + expect(sql).toContain(PREDICATE); + expect(sql).toContain('"test_posts"."slug" = '); + expect(params).toEqual(["published", "hello-world"]); + }); + + it("is applied by findById", async () => { + const { calls, service } = publicService([[storedRow]]); + + await service.findById(12); + + expect(lastWhere(calls).sql).toContain(PREDICATE); + expect(lastWhere(calls).sql).toContain('"test_posts"."id" = '); + }); + + it("is applied by findMany", async () => { + // A count query, then the page itself. + const { calls, service } = publicService([[{ count: 0 }], []]); + + await service.findMany(); + + expect(opsOf(calls, "where").length).toBeGreaterThan(0); + expect( + opsOf(calls, "where").some(condition => + dialect.sqlToQuery(condition as never).sql.includes(PREDICATE), + ), + ).toBe(true); + }); + + it("cannot be turned off by a caller", async () => { + const { calls, service } = publicService([[{ count: 0 }], []]); + + // There is no `where` argument and no `includeDrafts` flag on + // `ContentPublicFindManyArgs` - the predicate is not a parameter. + await service.findMany({ filters: { category: 3 } }); + + const combined = opsOf(calls, "where") + .map(condition => dialect.sqlToQuery(condition as never).sql) + .join(" "); + expect(combined).toContain(PREDICATE); + }); +}); + +describe("projection", () => { + it("selects only the allowlisted columns, plus id for the cursor", async () => { + const { calls, service } = publicService([[storedRow]]); + + await service.findBySlug("hello-world"); + + expect(Object.keys(opsOf(calls, "select")[0] as object).sort()).toEqual([ + "category", + "excerpt", + "id", + "publishedAt", + "slug", + "title", + ]); + }); + + it("never selects a private column", async () => { + const { calls, service } = publicService([[storedRow]]); + + await service.findBySlug("hello-world"); + + // `views`, `author` and `status` are not in `publicApi.fields`, so they do + // not leave Postgres in the first place. + const selected = Object.keys(opsOf(calls, "select")[0] as object); + expect(selected).not.toContain("views"); + expect(selected).not.toContain("author"); + expect(selected).not.toContain("status"); + }); + + it("returns exactly the allowlisted keys", async () => { + const { service } = publicService([[storedRow]]); + + const row = await service.findBySlug("hello-world"); + + expect(Object.keys(row ?? {}).sort()).toEqual([ + "category", + "excerpt", + "publishedAt", + "slug", + "title", + ]); + }); + + it("drops the cursor id, which the allowlist does not name", async () => { + // The one column fetched beyond the allowlist: `withPagination` reads the + // cursor off the row. It is removed again here, and that is the whole + // projection boundary. + const { service } = publicService([[storedRow]]); + + expect(await service.findBySlug("hello-world")).not.toHaveProperty("id"); + }); + + it("projects a relation down to an identifier", async () => { + const { service } = publicService([[storedRow]]); + + expect(await service.findBySlug("hello-world")).toMatchObject({ + category: { id: 3 }, + }); + }); + + it("puts no label on a relation", async () => { + // The only label available is the target's `admin.titleField` - admin + // metadata, from a row that may itself be a draft and may never have opted + // into a public API at all. + const row = await publicService([[storedRow]]).service.findBySlug("x"); + + expect(row?.category).toEqual({ id: 3 }); + expect(row?.category).not.toHaveProperty("label"); + }); + + it("joins nothing at all", async () => { + const { calls, service } = publicService([[storedRow]]); + + await service.findBySlug("hello-world"); + + // No target table is read, so no target column can be selected by mistake. + expect(opsOf(calls, "leftJoin")).toHaveLength(0); + }); + + it("never selects the target's title column", async () => { + const { calls, service } = publicService([[storedRow]]); + + await service.findBySlug("hello-world"); + + // `test.category`'s `admin.titleField` is `title`, reached through the + // `label__category` alias in the admin service. It is absent here. + const selected = Object.keys(opsOf(calls, "select")[0] as object); + expect(selected.some(name => name.startsWith("label__"))).toBe(false); + }); + + it("keeps a nullable relation null", async () => { + const { service } = publicService([[{ ...storedRow, category: null }]]); + + expect((await service.findBySlug("hello-world"))?.category).toBeNull(); + }); + + it("returns null for a missing row", async () => { + const { service } = publicService([[]]); + + await expect(service.findBySlug("nope")).resolves.toBeNull(); + }); +}); + +describe("filters", () => { + it("accepts a configured filterable field", async () => { + const { calls, service } = publicService([[{ count: 0 }], []]); + + await service.findMany({ filters: { category: 3 } }); + + const combined = opsOf(calls, "where") + .map(condition => dialect.sqlToQuery(condition as never)) + .find(query => query.sql.includes('"test_posts"."category" = ')); + expect(combined?.params).toContain(3); + }); + + it("rejects a field that is exposed but not filterable", async () => { + const { service } = publicService([[{ count: 0 }], []]); + + // `title` is public, but `filterableFields` is `["category"]`. Being + // readable does not make a column a query parameter. + await expect( + service.findMany({ filters: { title: "Hello" } }), + ).rejects.toThrow(/Filter "title" is not in the allowlist/); + }); + + it("rejects a private field", async () => { + const { service } = publicService([[{ count: 0 }], []]); + + await expect( + service.findMany({ + filters: { views: 10 } as never, + }), + ).rejects.toBeInstanceOf(ContentEngineError); + }); + + it("rejects the publication status, so drafts cannot be asked for", async () => { + const { service } = publicService([[{ count: 0 }], []]); + + await expect( + service.findMany({ filters: { status: "draft" } as never }), + ).rejects.toThrow(/not in the allowlist/); + }); +}); + +describe("search", () => { + it("scans only the configured searchable columns", async () => { + const { calls, service } = publicService([[{ count: 0 }], []]); + + await service.findMany({ query: { search: "hello" } }); + + const combined = opsOf(calls, "where") + .map(condition => dialect.sqlToQuery(condition as never).sql) + .join(" "); + expect(combined).toContain('"test_posts"."title" ilike'); + expect(combined).toContain('"test_posts"."excerpt" ilike'); + // A private column cannot be probed by searching for it either. + expect(combined).not.toContain('"test_posts"."views"'); + }); + + it("escapes the wildcards", async () => { + const { calls, service } = publicService([[{ count: 0 }], []]); + + await service.findMany({ query: { search: "100%" } }); + + const params = opsOf(calls, "where").flatMap( + condition => dialect.sqlToQuery(condition as never).params, + ); + expect(params).toContain("%100\\%%"); + }); +}); + +describe("ordering", () => { + it("accepts a column from the public allowlist", async () => { + const { calls, service } = publicService([[{ count: 0 }], []]); + + await service.findMany({ orderBy: { column: "title", order: "asc" } }); + + expect(opsOf(calls, "orderBy")).toHaveLength(1); + }); + + it("rejects a column the public allowlist does not name", async () => { + const { service } = publicService([[{ count: 0 }], []]); + + // Orderable in the AdminCP, but the public list has its own, smaller list. + await expect( + service.findMany({ orderBy: { column: "createdAt" as never } }), + ).rejects.toThrow(/Cannot order by "createdAt"/); + }); + + it("rejects a private column", async () => { + const { service } = publicService([[{ count: 0 }], []]); + + await expect( + service.findMany({ orderBy: { column: "views" as never } }), + ).rejects.toThrow(/Cannot order by "views"/); + }); + + it("falls back to the configured default", async () => { + const { calls, service } = publicService([[{ count: 0 }], []]); + + await service.findMany(); + + expect(opsOf(calls, "orderBy")).toHaveLength(1); + }); +}); + +describe("pagination", () => { + it("caps the page size below the admin limit", async () => { + const { calls, service } = publicService([[{ count: 0 }], []]); + + await service.findMany({ query: { first: "500" } }); + + // `withPagination` would otherwise clamp to 100 and ask for 101 rows. + expect(opsOf(calls, "limit")[0]).toBe(51); + }); + + it("leaves a reasonable page size alone", async () => { + const { calls, service } = publicService([[{ count: 0 }], []]); + + await service.findMany({ query: { first: "10" } }); + + expect(opsOf(calls, "limit")[0]).toBe(11); + }); +}); diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts new file mode 100644 index 000000000..ca7f602e9 --- /dev/null +++ b/packages/vitnode/src/content/server/public-service.ts @@ -0,0 +1,242 @@ +import type { ColumnBaseConfig, SQL } from "drizzle-orm"; +import type { + PgColumn, + PgTableWithColumns, + TableConfig, +} from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq } from "drizzle-orm"; + +import type { + AnyContentTypeDefinition, + ContentPublicFilterInput, + ContentPublicListRow, + ContentPublicOrderableFieldName, + ContentPublicSelect, +} from "../types"; +import type { ContentPageInfo } from "./service"; + +import { withPagination } from "../../api/lib/with-pagination"; +import { + CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, + CONTENT_PUBLIC_MAX_PAGE_SIZE, +} from "../const"; +import { ContentEngineError } from "../errors"; +import { publicOrderableColumns } from "../registry"; +import { publicationColumns, publishedCondition } from "./publication"; +import { + buildFilterCondition, + buildOrderColumn, + buildSearchCondition, +} from "./query"; + +export interface ContentPublicFindManyArgs { + /** Equality filters, restricted to `publicApi.filterableFields`. */ + filters?: ContentPublicFilterInput; + orderBy?: { + column?: ContentPublicOrderableFieldName; + order?: "asc" | "desc"; + }; + /** Raw pagination query (`cursor`, `first`, `last`, `search`). */ + query?: { cursor?: string; first?: string; last?: string; search?: string }; +} + +/** + * The read-only half of a content type, for anonymous callers. + * + * There is no `create`, `update`, `delete`, `publish` or `unpublish` to omit - + * this is a different object from `model.service`, not a filtered view of it, + * so a public write is not something you can reach by accident. + */ +export interface ContentPublicService { + /** `null` unless the row exists *and* is published. */ + findById: (id: number) => Promise | null>; + /** The public detail lookup. `null` for a draft, an unpublished row or a typo. */ + findBySlug: ( + slug: string, + ) => Promise | null>; + findMany: (args?: ContentPublicFindManyArgs) => Promise<{ + edges: ContentPublicListRow[]; + pageInfo: ContentPageInfo; + }>; +} + +/** Public pages are smaller than admin ones, and the cap is lower too. */ +const clampPageSize = (value: string | undefined): string | undefined => { + if (value === undefined) return undefined; + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) return value; + + return String(Math.min(parsed, CONTENT_PUBLIC_MAX_PAGE_SIZE)); +}; + +/** + * Builds the read-only service a public route serves from. + * + * Two things make this safe rather than "the admin service with fewer methods": + * + * 1. **The published predicate is not a parameter.** Every method `and`s it in + * itself, so there is no argument a caller could forget and no code path + * that reaches an unpublished row. + * 2. **The `SELECT` is built from `publicApi.fields`.** A private column is + * never fetched, so it cannot be leaked by a mistake further downstream. + * The one exception is `id`, which the cursor needs; it is dropped from the + * projected row unless the allowlist names it, and that boundary is tested. + * + * It also joins nothing. An exposed relation is projected from the foreign key + * the row already carries, so a target table is never read - which is what + * makes it impossible for one content type's allowlist to publish another's + * administrative metadata. + */ +export const createContentPublicService = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + columns, + definition, + table, +}: { + c: Context; + columns: Record; + definition: TDefinition; + table: PgTableWithColumns; +}): ContentPublicService => { + const contentTypeId = definition.id; + const publicApi = definition.publicApi; + + if (!publicApi.enabled) { + throw new ContentEngineError( + "This content type has no public API. Add `publicApi: { enabled: true, path, fields }` to generate one.", + { contentTypeId }, + ); + } + + const fields = definition.fields; + // `publicApi` cannot be enabled without publication, so this never throws + // here - it is what turns the erased column map into the two columns the + // predicate needs. + const published = publicationColumns(definition, columns); + const primaryCursor = columns.id as PgColumn< + ColumnBaseConfig<"number", string> + >; + const exposed = publicApi.fields; + const exposesId = exposed.includes("id"); + // A `user` field is never exposable, so this is only ever relations. + const exposedRelations = new Set( + exposed.filter(name => fields[name]?.kind === "relation"), + ); + const searchColumns = publicApi.searchableFields.map(name => columns[name]); + const orderable = publicOrderableColumns(definition); + + /** Own columns, plus `id` for the cursor whether or not it is exposed. */ + const selection = (): Record => ({ + id: primaryCursor, + ...Object.fromEntries(exposed.map(name => [name, columns[name]])), + }); + + /** + * Turns one raw row into the public projection: relations collapse to + * `{ id }`, and the cursor `id` disappears unless the allowlist asked for it. + * + * The relation identifier is the foreign key already on this row, so no + * target table is read and no label is invented. + */ + const project = ( + row: Record, + ): ContentPublicSelect => { + const projected: Record = {}; + + for (const name of exposed) { + if (!exposedRelations.has(name)) { + projected[name] = row[name]; + continue; + } + + const id = row[name]; + projected[name] = typeof id === "number" ? { id } : null; + } + + if (exposesId) projected.id = row.id; + + return projected as ContentPublicSelect; + }; + + const readOne = async ( + condition: SQL, + ): Promise | null> => { + const [row] = await c + .get("db") + .select(selection()) + .from(table) + .where(and(publishedCondition(published), condition)) + .limit(1); + + return row ? project(row) : null; + }; + + return { + findById: async id => await readOne(eq(primaryCursor, id)), + + findBySlug: async slug => + await readOne(eq(columns[publicApi.slugField], slug)), + + findMany: async ({ filters = {}, orderBy, query = {} } = {}) => { + const conditions = [ + // Not optional, not a parameter, and first: whatever else a caller + // passes, an unpublished row cannot come back. + publishedCondition(published), + buildFilterCondition({ + allowed: publicApi.filterableFields, + columns, + contentTypeId, + fields, + filters, + }), + buildSearchCondition(searchColumns, query.search), + ].filter((item): item is SQL => item !== undefined); + + const data = await withPagination({ + c, + params: { + query: { + ...query, + first: clampPageSize(query.first), + last: clampPageSize(query.last), + // Folded into `where` above so the term is escaped; handing it to + // `withPagination` would build an unescaped `ilike`. + search: undefined, + }, + }, + primaryCursor, + orderBy: { + column: buildOrderColumn({ + columns, + contentTypeId, + fallback: publicApi.defaultOrderBy, + orderBy: orderBy?.column, + orderable, + }), + order: orderBy?.order ?? publicApi.defaultOrder, + }, + table, + where: conditions.length > 1 ? and(...conditions) : conditions[0], + query: async ({ limit, orderBy: order, where }) => + await c + .get("db") + .select(selection()) + .from(table) + .where(where) + .orderBy(order) + .limit( + typeof limit === "number" + ? Math.min(limit, CONTENT_PUBLIC_MAX_PAGE_SIZE + 1) + : CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, + ), + }); + + return { edges: data.edges.map(project), pageInfo: data.pageInfo }; + }, + }; +}; diff --git a/packages/vitnode/src/content/server/publication.test-d.ts b/packages/vitnode/src/content/server/publication.test-d.ts new file mode 100644 index 000000000..b3a488278 --- /dev/null +++ b/packages/vitnode/src/content/server/publication.test-d.ts @@ -0,0 +1,51 @@ +import type { PgColumn } from "drizzle-orm/pg-core"; + +import { describe, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { publishedCondition } from "./publication"; + +const categories = createContentModel(testCategoryContentType); +const articles = createContentModel(testArticleContentType, { + references: { category: () => categories.table.id }, +}); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); + +describe("publishedCondition", () => { + it("accepts the columns of a publication content type", () => { + void publishedCondition(posts.columns); + }); + + it("rejects the columns of a content type without publication", () => { + // @ts-expect-error - publication is off, so there is no `status` or + // `publishedAt` column to compare against + void publishedCondition(categories.columns); + }); + + it("accepts a hand-assembled pair of columns", () => { + // Structural, deliberately: the helper is a predicate over two columns, not + // over a `ContentModel`, so a custom table with the same two names works. + const manual: { publishedAt: PgColumn; status: PgColumn } = { + publishedAt: posts.columns.publishedAt, + status: posts.columns.status, + }; + + void publishedCondition(manual); + }); + + it("accepts a Stage 1 content type that declares both names itself", () => { + // The flip side of being structural. This fixture declares its own `status` + // enum and `publishedAt` date field, so the predicate compiles and compares + // real columns - it just is not the generated lifecycle. Enabling + // `publication` is what makes the two columns mean what this helper assumes. + void publishedCondition(articles.columns); + }); +}); diff --git a/packages/vitnode/src/content/server/publication.test.ts b/packages/vitnode/src/content/server/publication.test.ts new file mode 100644 index 000000000..16346e30b --- /dev/null +++ b/packages/vitnode/src/content/server/publication.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node +import type { SQL } from "drizzle-orm"; + +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { + testCategoryContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError } from "../errors"; +import { createContentModel } from "./model"; +import { + publicationColumns, + publicationMethods, + publishedCondition, +} from "./publication"; + +const categories = createContentModel(testCategoryContentType); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); + +const dialect = new PgDialect(); + +/** The SQL text and bound parameters Drizzle would actually send. */ +const compile = (condition: SQL | undefined) => { + if (!condition) throw new Error("Expected a condition."); + + return dialect.sqlToQuery(condition); +}; + +describe("publishedCondition", () => { + it("compiles the full published invariant", () => { + const { params, sql } = compile(publishedCondition(posts.columns)); + + // All three clauses, in one predicate. Dropping `IS NOT NULL` would leak a + // row whose timestamp was cleared by hand, which is the whole reason this + // is exported rather than written out at each call site. + expect(sql).toBe( + '("test_posts"."status" = $1 and "test_posts"."publishedAt" is not null and "test_posts"."publishedAt" <= now())', + ); + expect(params).toEqual(["published"]); + }); + + it("binds the status rather than inlining it", () => { + expect(compile(publishedCondition(posts.columns)).sql).not.toContain( + "'published'", + ); + }); +}); + +describe("publicationColumns", () => { + it("picks the two columns out of an erased map", () => { + // Generic code holds `Record`, which does not satisfy the + // narrowed parameter type. This is the runtime step that makes it true. + const narrowed = publicationColumns(testPostContentType, posts.columns); + + expect(Object.keys(narrowed).sort()).toEqual(["publishedAt", "status"]); + expect(compile(publishedCondition(narrowed)).params).toEqual(["published"]); + }); + + it("throws for a content type without publication", () => { + expect(() => + publicationColumns(testCategoryContentType, categories.columns), + ).toThrow(ContentEngineError); + }); + + it("throws when the columns are missing, whatever the flag says", () => { + // Belt and braces: the presence check is what stops `undefined` reaching + // Drizzle if a caller hands over the wrong column map. + expect(() => publicationColumns(testPostContentType, {})).toThrow( + ContentEngineError, + ); + }); +}); + +describe("publicationMethods", () => { + it("returns the publish methods for a publication content type", () => { + const service = posts.service({ get: () => undefined } as never); + const methods = publicationMethods(testPostContentType, service); + + expect(typeof methods.publish).toBe("function"); + expect(typeof methods.unpublish).toBe("function"); + }); + + it("throws for a content type without publication", () => { + const service = categories.service({ get: () => undefined } as never); + + expect(() => publicationMethods(testCategoryContentType, service)).toThrow( + ContentEngineError, + ); + expect(() => publicationMethods(testCategoryContentType, service)).toThrow( + /publication: \{ enabled: true \}/, + ); + }); +}); diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts new file mode 100644 index 000000000..1c8471d45 --- /dev/null +++ b/packages/vitnode/src/content/server/publication.ts @@ -0,0 +1,121 @@ +import type { SQL } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; + +import { and, eq, isNotNull, lte, sql } from "drizzle-orm"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentPublicationMethods, ContentService } from "./service"; + +import { ContentEngineError } from "../errors"; + +/** + * The two columns `publication: { enabled: true }` generates. + * + * Structural on purpose: a `ContentModel`'s `columns` map satisfies it only when + * publication is enabled, because `ContentColumnName` adds those two names under + * the same conditional. Passing the columns of a content type without + * publication is therefore a compile error rather than a query against columns + * that do not exist. + */ +export interface PublicationColumns { + publishedAt: PgColumn; + status: PgColumn; +} + +/** + * Picks the two publication columns out of a model's column map. + * + * Generic code is written against `AnyContentTypeDefinition`, whose + * `publication.enabled` is `boolean`, so its `columns` map is a plain + * `Record` and does not satisfy {@link PublicationColumns}. + * This is the runtime step that makes it true - a real presence check rather + * than a cast, since the whole point of narrowing the parameter was to stop + * `undefined` reaching Drizzle. + */ +export const publicationColumns = ( + definition: AnyContentTypeDefinition, + columns: Record, +): PublicationColumns => { + const { publishedAt, status } = columns; + + if (!definition.publication.enabled || !publishedAt || !status) { + throw new ContentEngineError( + "The published predicate needs `publication: { enabled: true }` on the content type.", + { contentTypeId: definition.id }, + ); + } + + return { publishedAt, status }; +}; + +/** + * The one definition of "published". + * + * ```sql + * status = 'published' AND published_at IS NOT NULL AND published_at <= NOW() + * ``` + * + * The generated public read layer applies this centrally: every method on + * `model.publicService` `and`s it in itself, so there is no argument a caller + * could forget, and the two generated public routes go through that service. + * + * It is also exported for **hand-written plugin queries**, which is where the + * predicate would otherwise be retyped by hand - exactly the thing worth + * getting wrong once, because forgetting the `IS NOT NULL` leaks a row whose + * timestamp was cleared: + * + * ```ts + * const rows = await c + * .get("db") + * .select({ id: articles.table.id, title: articles.table.title }) + * .from(articles.table) + * .where( + * publishedCondition(publicationColumns(articleContentType, articles.columns)), + * ); + * ``` + * + * One definition either way, so a custom route and a generated one can never + * disagree about what "published" means. + * + * `published_at <= now()` is always true today - `publish` only ever stamps + * `now()` - but stating the invariant costs nothing and makes scheduled + * publishing a purely additive change later. + * + * Enabling `publication` still exposes nothing on its own: it adds the + * lifecycle, and `publicApi.enabled` is what generates the public routes. On a + * content type without that block, this predicate is only ever reached by a + * route you wrote. + */ +export const publishedCondition = ( + columns: PublicationColumns, +): SQL | undefined => + and( + eq(columns.status, "published"), + isNotNull(columns.publishedAt), + lte(columns.publishedAt, sql`now()`), + ); + +/** + * Narrows a service to its publication methods. + * + * Route and module code is generic over `AnyContentTypeDefinition`, whose + * `publication.enabled` is `boolean` rather than `true`, so the conditional + * members resolve to `never` there. Every call site checks + * `definition.publication.enabled` first - this is the accompanying type-level + * step, in the same spirit as the `isReferenceField` predicate in `routes.ts`. + */ +export const publicationMethods = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + service: ContentService, +): ContentPublicationMethods => { + if (!definition.publication.enabled) { + throw new ContentEngineError( + "publish/unpublish need `publication: { enabled: true }` on the content type.", + { contentTypeId: definition.id }, + ); + } + + return service as unknown as ContentPublicationMethods; +}; diff --git a/packages/vitnode/src/content/server/query.test.ts b/packages/vitnode/src/content/server/query.test.ts index 3e71a6baa..c21b2357a 100644 --- a/packages/vitnode/src/content/server/query.test.ts +++ b/packages/vitnode/src/content/server/query.test.ts @@ -9,6 +9,7 @@ import { field } from "@/content/fields"; import { testArticleContentType, testCategoryContentType, + testPostContentType, } from "@/tests/content-fixtures"; import { ContentEngineError } from "../errors"; @@ -62,6 +63,12 @@ const referenceTable = createContentTable(referenceType, { }); const referenceColumns = contentTableColumns(referenceType, referenceTable); +/** Publication enabled, and deliberately declaring neither generated name. */ +const postTable = createContentTable(testPostContentType, { + references: { category: () => categories.id }, +}); +const postColumns = contentTableColumns(testPostContentType, postTable); + const dialect = new PgDialect(); /** The SQL text and bound parameters Drizzle would actually send. */ @@ -228,6 +235,94 @@ describe("buildFilterCondition", () => { ); }); }); + + /** + * `status` on a publication content type is a *generated* column: there is no + * field descriptor behind it, so none of the checks above apply and it needs + * its own guard. The generated Zod schema narrows the value on the HTTP path; + * these cover the direct-service path, where a cast or a runtime-built object + * can carry anything. + */ + describe("publication status", () => { + const publicationFilter = (filters: Record) => + buildFilterCondition({ + columns: postColumns, + contentTypeId: testPostContentType.id, + fields: testPostContentType.fields, + filters, + publication: true, + }); + + it.each(["draft", "published"])( + "filters by the generated %s status", + status => { + const { params, sql } = compile(publicationFilter({ status })); + + expect(sql).toBe('"test_posts"."status" = $1'); + expect(params).toEqual([status]); + }, + ); + + it("combines with an ordinary field filter", () => { + const { params, sql } = compile( + publicationFilter({ category: 3, status: "published" }), + ); + + expect(sql).toBe( + '("test_posts"."category" = $1 and "test_posts"."status" = $2)', + ); + expect(params).toEqual([3, "published"]); + }); + + it.each([ + ["a value from a Stage 1 enum", "archived"], + ["an unrelated string", "sideways"], + ["an empty string", ""], + ["a number", 1], + ["null", null], + ["a boolean", true], + ["an object", {}], + ])("rejects %s before it reaches SQL", (_case, status) => { + expect(() => publicationFilter({ status })).toThrow(ContentEngineError); + }); + + it("names the value and the allowed set", () => { + expect(() => publicationFilter({ status: "archived" })).toThrow( + /Invalid publication status "archived"\. Allowed values: draft, published\./, + ); + }); + + it("names the content type", () => { + expect(() => publicationFilter({ status: "archived" })).toThrow( + /test\.post/, + ); + }); + + it("still rejects an unknown filter name with the unknown-filter error", () => { + expect(() => publicationFilter({ nope: 1 })).toThrow( + /Unknown filter "nope"/, + ); + }); + + // The guard is keyed on `publication`, not on the column name, so a Stage 1 + // content type that declares its own `status` enum is untouched by it. + it("leaves a declared status enum on its own values", () => { + expect(compile(filter({ status: "archived" })).params).toEqual([ + "archived", + ]); + }); + + it("does not accept a status filter when publication is off", () => { + expect(() => + buildFilterCondition({ + columns: postColumns, + contentTypeId: testPostContentType.id, + fields: testPostContentType.fields, + filters: { status: "published" }, + }), + ).toThrow(/Unknown filter "status"/); + }); + }); }); describe("buildOrderColumn", () => { diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index 0f30bd419..2659e96bf 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -7,6 +7,8 @@ import type { ContentFieldDescriptor, ContentFieldMap } from "../types"; import { CONTENT_FILTERABLE_FIELD_KINDS, + CONTENT_PUBLICATION_STATUSES, + isContentPublicationStatus, isFilterableFieldKind, } from "../const"; import { ContentEngineError } from "../errors"; @@ -54,21 +56,55 @@ const filterValue = ( * and the type is not what protects the query. */ export const buildFilterCondition = ({ + allowed, columns, contentTypeId, fields, filters, + publication = false, }: { + /** + * Narrows the filterable set further, for a caller with its own allowlist - + * the public service, whose `filterableFields` is a deliberate subset of + * what the admin list accepts. + */ + allowed?: readonly string[]; columns: Record; contentTypeId: string; fields: ContentFieldMap; filters: Record; + /** Whether `status` is a generated column and therefore filterable. */ + publication?: boolean; }): SQL | undefined => { const conditions: SQL[] = []; for (const [name, raw] of Object.entries(filters)) { if (raw === undefined) continue; + if (allowed && !allowed.includes(name)) { + throw new ContentEngineError( + `Filter "${name}" is not in the allowlist. Allowed: ${allowed.length > 0 ? allowed.join(", ") : "(none)"}.`, + { contentTypeId }, + ); + } + + // `status` is a generated column, not a declared field, so there is no + // descriptor to drive the checks below. The generated schema's `z.enum` + // already narrowed it on the HTTP path; this re-checks the value for the + // direct-service path, where a cast or a runtime-built object could put + // anything here. + if (publication && name === "status" && columns.status) { + if (!isContentPublicationStatus(raw)) { + throw new ContentEngineError( + `Invalid publication status ${JSON.stringify(raw)}. Allowed values: ${CONTENT_PUBLICATION_STATUSES.join(", ")}.`, + { contentTypeId }, + ); + } + + conditions.push(eq(columns.status, raw)); + continue; + } + const fieldValue = fields[name]; const column = columns[name]; if (!fieldValue || !column) { diff --git a/packages/vitnode/src/content/server/references.ts b/packages/vitnode/src/content/server/references.ts new file mode 100644 index 000000000..dd00ce03c --- /dev/null +++ b/packages/vitnode/src/content/server/references.ts @@ -0,0 +1,98 @@ +import type { + PgColumn, + PgTable, + PgTableWithColumns, + TableConfig, +} from "drizzle-orm/pg-core"; + +import { alias, getTableConfig } from "drizzle-orm/pg-core"; + +import type { AnyContentTypeDefinition } from "../types"; + +import { ContentEngineError } from "../errors"; + +export interface ReferenceTarget { + /** Aliased, so two relations pointing at the same table can both be joined. */ + aliased: PgTable; + idColumn: PgColumn; + labelColumn: PgColumn; + owner: PgColumn; +} + +export const LABEL_PREFIX = "label__"; + +/** + * Turns a joined label column value into display text. Only the shapes a title + * column can actually hold are handled - anything else becomes `null` rather + * than "[object Object]". + */ +export const toLabel = (value: unknown): null | string => { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) return value.toISOString(); + + return null; +}; + +/** + * Works out which table and column supply the display label for each + * `user`/`relation` field. + * + * The target comes from the foreign keys Drizzle already resolved on the table, + * so the engine needs no separate table registry - and because the FK thunk is + * evaluated here, circular content type references stay safe. + * + * **Administrative only.** A label is read from the target's + * `admin.titleField`, which is metadata for the AdminCP: it may name a field + * the target never publishes, and the row it comes from may itself be a draft. + * The public projection therefore does not use this at all - an exposed + * relation there is `{ id }`, taken straight off the foreign key. + */ +export const resolveReferenceTargets = ( + definition: AnyContentTypeDefinition, + table: PgTableWithColumns, + columns: Record, +): Record => { + const fields = definition.fields; + const byOwnerColumn = new Map( + getTableConfig(table) + .foreignKeys.map(foreignKey => foreignKey.reference()) + .map(reference => [reference.columns[0]?.name, reference]), + ); + + const targets: Record = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "relation" && fieldValue.kind !== "user") continue; + + const reference = byOwnerColumn.get(name); + if (!reference) { + throw new ContentEngineError( + `Field "${name}" has no foreign key on "${definition.tableName}".`, + { contentTypeId: definition.id }, + ); + } + + // `user` labels come from the core users table; a relation uses the target + // content type's own `admin.titleField`. + const labelName = + fieldValue.kind === "user" + ? "name" + : (fieldValue.target().admin.titleField ?? "id"); + + const aliased = alias(reference.foreignTable, `${LABEL_PREFIX}${name}`); + const aliasedColumns = aliased as unknown as Record; + + targets[name] = { + aliased, + idColumn: aliasedColumns.id, + labelColumn: aliasedColumns[labelName] ?? aliasedColumns.id, + owner: columns[name], + }; + } + + return targets; +}; diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index 8b2969837..f7c1f7c51 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { testArticleContentType, testCategoryContentType, + testPostContentType, } from "@/tests/content-fixtures"; import { createContentModel } from "./model"; @@ -30,6 +31,9 @@ const categories = createContentModel(testCategoryContentType); const articles = createContentModel(testArticleContentType, { references: { category: () => categories.table.id }, }); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); const PLUGIN_ID = "@vitnode/example"; @@ -96,6 +100,47 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { return { app, emitted, service }; }; +/** + * The same harness for a content type with publication enabled, which adds two + * routes and two service methods. + */ +const publicationHarness = ({ allow = true }: { allow?: boolean } = {}) => { + const emitted: Harness["emitted"] = []; + const service = { + create: vi.fn(), + delete: vi.fn(), + findById: vi.fn(), + findMany: vi.fn(), + options: vi.fn(), + publish: vi.fn(), + unpublish: vi.fn(), + update: vi.fn(), + }; + + permissionGranted = allow; + vi.spyOn(posts, "service").mockReturnValue(service); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("events", { + emit: async (name: string, payload: unknown) => { + await Promise.resolve(); + emitted.push({ name, payload }); + }, + } as unknown as Context["var"]["events"]); + c.set("admin", allow ? { user: adminUser } : null); + await next(); + }); + + for (const { handler, route } of buildContentRoutes(posts, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, emitted, service }; +}; + const json = (body: unknown) => ({ body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, @@ -412,6 +457,125 @@ describe("generated content routes", () => { }); }); + describe("publication", () => { + const publishedRow = { + ...row, + publishedAt: new Date("2026-08-01T09:00:00.000Z"), + status: "published" as const, + }; + + it("generates no publish routes without publication", () => { + const paths = buildContentRoutes(articles, { pluginId: PLUGIN_ID }).map( + entry => `${entry.route.method} ${entry.route.path}`, + ); + + expect(paths).not.toContain("post /{id}/publish"); + expect(paths).not.toContain("post /{id}/unpublish"); + }); + + it.each([ + ["publish", publishedRow, "published"], + ["unpublish", row, "unpublished"], + ] as const)( + "%ss and emits the matching event", + async (action, resultRow, event) => { + const { app, emitted, service } = publicationHarness(); + service[action].mockResolvedValue({ + changed: true, + publishedAt: publishedRow.publishedAt, + row: resultRow, + }); + + const res = await app.request(`/7/${action}`, { method: "POST" }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ changed: true }); + expect(service[action]).toHaveBeenCalledWith(7); + expect(emitted).toHaveLength(1); + expect(emitted[0].name).toBe(`content.test.post.${event}`); + }, + ); + + it("carries the publication date on the published event only", async () => { + const { app, emitted, service } = publicationHarness(); + service.publish.mockResolvedValue({ + changed: true, + publishedAt: publishedRow.publishedAt, + row: publishedRow, + }); + + await app.request("/7/publish", { method: "POST" }); + + expect(emitted[0].payload).toEqual({ + contentId: 7, + publishedAt: publishedRow.publishedAt, + }); + }); + + it("answers 200 but emits nothing when nothing changed", async () => { + const { app, emitted, service } = publicationHarness(); + service.publish.mockResolvedValue({ + changed: false, + publishedAt: publishedRow.publishedAt, + row: publishedRow, + }); + + const res = await app.request("/7/publish", { method: "POST" }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ changed: false }); + // No outbox, so a listener firing on every button press would be doing + // duplicate work for free. + expect(emitted).toEqual([]); + }); + + it("answers 404 for a missing record", async () => { + const { app, service } = publicationHarness(); + service.publish.mockResolvedValue(null); + + const res = await app.request("/7/publish", { method: "POST" }); + + expect(res.status).toBe(404); + }); + + it("answers 400 for a non-numeric identifier", async () => { + const { app } = publicationHarness(); + + const res = await app.request("/abc/publish", { method: "POST" }); + + expect(res.status).toBe(400); + }); + + it.each(["publish", "unpublish"])( + "requires can_publish for %s", + async action => { + const { app } = publicationHarness({ allow: false }); + + const res = await app.request(`/7/${action}`, { method: "POST" }); + + expect(res.status).toBe(403); + }, + ); + + it("documents both operations", () => { + const doc = publicationHarness().app.getOpenAPIDocument({ + info: { title: "t", version: "1" }, + openapi: "3.0.0", + }); + + expect(Object.keys(doc.paths).sort()).toEqual([ + "/", + "/options/{field}", + "/{id}", + "/{id}/publish", + "/{id}/unpublish", + ]); + expect( + Object.keys(doc.paths["/{id}/publish"].post?.responses ?? {}).sort(), + ).toEqual(["200", "400", "404"]); + }); + }); + describe("OpenAPI", () => { const document = () => harness().app.getOpenAPIDocument({ diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index bcf4a345b..00304fd09 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -19,6 +19,7 @@ import { CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS } from "../const"; import { orderableColumns } from "../registry"; import { emitContentEvent } from "./emit"; import { withHttpErrors } from "./http-errors"; +import { publicationMethods } from "./publication"; const zodLabels = z.record(z.string(), z.string().nullable()); @@ -59,6 +60,11 @@ export const buildContentRoutes = < const label = definition.admin.label; const listRow = schemas.selectObject.extend({ labels: zodLabels }); + const publicationResponse = z.object({ + /** `false` when the record was already in the requested state. */ + changed: z.boolean(), + row: schemas.selectObject, + }); const referenceFieldNames = Object.entries(definition.fields) .filter( @@ -278,6 +284,55 @@ export const buildContentRoutes = < }, }); + // Publishing is a domain operation, not a field update: `status` and + // `publishedAt` are absent from the strict create/update schemas, so these + // two routes are the only way to move them over HTTP. Both are idempotent - + // publishing an already-published record is a 200 that changed nothing. + const publicationRoute = (action: "publish" | "unpublish") => + buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.publish }, + route: { + method: "post", + path: `/{id}/${action}` as const, + description: `${action === "publish" ? "Publish" : "Unpublish"} a ${label.singular}`, + request: { params: schemas.params }, + responses: { + 200: jsonResponse( + publicationResponse, + `${label.singular} ${action}ed, or already in that state`, + ), + 400: invalidIdentifier, + 404: { description: `${label.singular} not found` }, + }, + }, + handler: async c => { + const id = identifier(c); + const service = publicationMethods(definition, model.service(c)); + + const result = await withHttpErrors( + "update", + async () => await service[action](id), + ); + if (!result) throw notFound(definition); + + // A no-op emits nothing: there is no outbox, so a listener that fires + // on every button press would be doing duplicate work for free. + if (result.changed) { + await emitContentEvent( + c, + definition, + action === "publish" ? "published" : "unpublished", + action === "publish" && result.publishedAt + ? { contentId: id, publishedAt: result.publishedAt } + : { contentId: id }, + ); + } + + return c.json({ changed: result.changed, row: result.row }, 200); + }, + }); + const remove = buildRoute({ pluginId, adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, @@ -308,5 +363,15 @@ export const buildContentRoutes = < }, }); - return [list, options, detail, create, update, remove]; + return [ + list, + options, + detail, + create, + update, + remove, + ...(definition.publication.enabled + ? [publicationRoute("publish"), publicationRoute("unpublish")] + : []), + ]; }; diff --git a/packages/vitnode/src/content/server/service.test-d.ts b/packages/vitnode/src/content/server/service.test-d.ts index 5607e6fd2..1e42f0f91 100644 --- a/packages/vitnode/src/content/server/service.test-d.ts +++ b/packages/vitnode/src/content/server/service.test-d.ts @@ -5,6 +5,7 @@ import { describe, expectTypeOf, it } from "vitest"; import { testArticleContentType, testCategoryContentType, + testPostContentType, } from "@/tests/content-fixtures"; import type { @@ -20,11 +21,16 @@ const categories = createContentModel(testCategoryContentType); const articles = createContentModel(testArticleContentType, { references: { category: () => categories.table.id }, }); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); type ArticleType = typeof testArticleContentType; // Never executed - the type checker is the whole point. const service = articles.service({} as Context); +const postService = posts.service({} as Context); +const categoryService = categories.service({} as Context); describe("findMany filters", () => { it("accepts every filterable field", () => { @@ -104,6 +110,71 @@ describe("findMany ordering", () => { }); }); +describe("publication ordering", () => { + it("accepts the generated columns on a publication content type", () => { + void postService.findMany({ orderBy: { column: "status" } }); + void postService.findMany({ + orderBy: { column: "publishedAt", order: "desc" }, + }); + }); + + it("still accepts declared fields and system columns", () => { + void postService.findMany({ orderBy: { column: "title" } }); + void postService.findMany({ orderBy: { column: "updatedAt" } }); + }); + + // The category fixture has publication disabled *and* declares neither name, + // which is what makes this a real negative. The article fixture would pass + // for the wrong reason: it declares its own `status` and `publishedAt`. + it("does not invent them for a content type without publication", () => { + // @ts-expect-error - `status` is not a column of this content type + void categoryService.findMany({ orderBy: { column: "status" } }); + // @ts-expect-error - `publishedAt` is not a column of this content type + void categoryService.findMany({ orderBy: { column: "publishedAt" } }); + }); + + it("leaves a Stage 1 content type ordering by its own fields", () => { + // Accepted because they are declared fields, not generated columns. + void service.findMany({ orderBy: { column: "status" } }); + void service.findMany({ orderBy: { column: "publishedAt" } }); + }); +}); + +describe("publication filters", () => { + it("accepts the two generated statuses", () => { + void postService.findMany({ filters: { status: "draft" } }); + void postService.findMany({ filters: { status: "published" } }); + }); + + it("rejects anything else", () => { + void postService.findMany({ + // @ts-expect-error - "archived" is not a generated publication status + filters: { status: "archived" }, + }); + }); + + it("is absent from a content type without publication", () => { + void categoryService.findMany({ + // @ts-expect-error - no `status` column to filter on + filters: { status: "draft" }, + }); + }); +}); + +describe("publication service methods", () => { + it("exist on a publication content type", () => { + void postService.publish(1); + void postService.unpublish(1, {}); + }); + + it("are absent everywhere else", () => { + // @ts-expect-error - publication is not enabled on this content type + void service.publish(1); + // @ts-expect-error - publication is not enabled on this content type + void categoryService.unpublish(1); + }); +}); + describe("options", () => { it("accepts relation and user fields", () => { void service.options("category"); diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts index 5c74280b2..8123e566a 100644 --- a/packages/vitnode/src/content/server/service.test.ts +++ b/packages/vitnode/src/content/server/service.test.ts @@ -7,6 +7,7 @@ import { ZodError } from "zod"; import { testArticleContentType, testCategoryContentType, + testPostContentType, } from "@/tests/content-fixtures"; import type { @@ -15,7 +16,9 @@ import type { ContentUpdateInput, } from "../types"; -import { ContentEngineError } from "../errors"; +import { defineContentType } from "../define"; +import { ContentEngineError, ContentInputError } from "../errors"; +import { field } from "../fields"; import { createContentModel } from "./model"; type ArticleType = typeof testArticleContentType; @@ -24,6 +27,9 @@ const categories = createContentModel(testCategoryContentType); const articles = createContentModel(testArticleContentType, { references: { category: () => categories.table.id }, }); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); interface RecordedCall { arg: unknown; @@ -437,4 +443,249 @@ describe("content service", () => { expect(opsOf(outer.calls, "insert")).toHaveLength(1); }); }); + + describe("publication", () => { + const published = { + id: 1, + publishedAt: new Date("2026-08-01T09:00:00.000Z"), + status: "published", + title: "Hello", + }; + const draft = { ...published, status: "draft" }; + + it("is absent from a content type without publication", () => { + const service = articles.service(createDbMock([]).c); + + expect(service.publish).toBeUndefined(); + expect(service.unpublish).toBeUndefined(); + }); + + describe("publish", () => { + it("writes the status and coalesces the publication date", async () => { + const { c, calls } = createDbMock([[published]]); + + const result = await posts.service(c).publish?.(1); + + expect(result).toEqual({ + changed: true, + publishedAt: published.publishedAt, + row: published, + }); + // COALESCE, so a republish keeps the original date - it is passed as + // SQL rather than a JS value on purpose. + expect(opsOf(calls, "set")).toHaveLength(1); + expect(opsOf(calls, "set")[0]).toMatchObject({ status: "published" }); + // One statement in the happy path: no read-then-write race. + expect(opsOf(calls, "select")).toHaveLength(0); + }); + + it("is a no-op when the row is already published", async () => { + // The conditional UPDATE matches nothing, so the follow-up read is what + // tells "already published" apart from "no such row". + const { c, calls } = createDbMock([[], [published]]); + + const result = await posts.service(c).publish?.(1); + + expect(result).toEqual({ + changed: false, + publishedAt: published.publishedAt, + row: published, + }); + expect(opsOf(calls, "select")).toHaveLength(1); + }); + + it("returns null when the row does not exist", async () => { + const { c } = createDbMock([[], []]); + + await expect(posts.service(c).publish?.(1)).resolves.toBeNull(); + }); + + it("joins a caller's transaction", async () => { + const { c } = createDbMock([]); + const outer = createDbMock([[published]]); + + await posts.service(c).publish?.(1, { tx: outer.c.get("db") }); + + expect(opsOf(outer.calls, "update")).toHaveLength(1); + }); + }); + + describe("unpublish", () => { + it("flips the status and leaves the publication date alone", async () => { + const { c, calls } = createDbMock([[draft]]); + + const result = await posts.service(c).unpublish?.(1); + + expect(result).toEqual({ + changed: true, + publishedAt: draft.publishedAt, + row: draft, + }); + // `publishedAt` means "first published at", so unpublishing must not + // clear it - a republish would otherwise reorder the public feed. + expect(opsOf(calls, "set")).toEqual([{ status: "draft" }]); + }); + + it("is a no-op when the row is already a draft", async () => { + const { c } = createDbMock([[], [draft]]); + + await expect(posts.service(c).unpublish?.(1)).resolves.toMatchObject({ + changed: false, + }); + }); + + it("returns null when the row does not exist", async () => { + const { c } = createDbMock([[], []]); + + await expect(posts.service(c).unpublish?.(1)).resolves.toBeNull(); + }); + }); + + it("selects the generated columns on every read", async () => { + const { c, calls } = createDbMock([[published]]); + + await posts.service(c).findById(1); + + expect(Object.keys(opsOf(calls, "select")[0] as object)).toEqual( + expect.arrayContaining(["status", "publishedAt"]), + ); + }); + }); + + describe("slug", () => { + const createPost = async (values: Record) => { + const { c, calls } = createDbMock([[{ id: 1 }]]); + + await posts + .service(c) + .create(values as ContentCreateInput); + + return opsOf(calls, "values")[0] as Record; + }; + + const updatePost = async ( + current: Record, + values: Record, + ) => { + const { c, calls } = createDbMock([[current], [{ ...current }]]); + + await posts.service(c).update(1, values); + + return opsOf(calls, "set")[0] as Record | undefined; + }; + + describe("create", () => { + it("derives the slug from the source field", async () => { + const values = await createPost({ category: 2, title: "Hello World" }); + + expect(values.slug).toBe("hello-world"); + }); + + it("normalises a slug the caller supplied", async () => { + const values = await createPost({ + category: 2, + slug: " Hello World! ", + title: "Something else", + }); + + // Supplied, so the source is ignored - but it is still normalised, + // because the same rules have to hold whoever wrote the value. + expect(values.slug).toBe("hello-world"); + }); + + it("transliterates the source", async () => { + const values = await createPost({ category: 2, title: "Zażółć gęślą" }); + + expect(values.slug).toBe("zazolc-gesla"); + }); + + it("rejects a source that folds to nothing", async () => { + // No random suffix and no numeric fallback: an unaddressable row is + // refused, and the message says how to fix it. + await expect( + createPost({ category: 2, title: "日本語のタイトル" }), + ).rejects.toThrow(/Could not derive "slug" from "title"/); + }); + + it("rejects a supplied slug that folds to nothing", async () => { + await expect( + createPost({ category: 2, slug: "!!!", title: "Fine title" }), + ).rejects.toThrow(/normalises to an empty slug/); + }); + + it("reports the failure as a client error", async () => { + // `ContentInputError` is what the generated routes turn into a 400; + // every other engine error is a configuration bug and a 500. + await expect( + createPost({ category: 2, title: "🎉🎉🎉" }), + ).rejects.toBeInstanceOf(ContentInputError); + }); + + it("truncates to the descriptor's maxLength", async () => { + const short = createContentModel( + defineContentType({ + id: "test.short-slug", + tableName: "test_short_slugs", + fields: { + title: field.text({ required: true }), + slug: field.slug({ maxLength: 8, source: "title" }), + }, + admin: { label: { plural: "Shorts", singular: "Short" } }, + }), + ); + const { c, calls } = createDbMock([[{ id: 1 }]]); + + await short.service(c).create({ title: "Hello World" }); + + expect((opsOf(calls, "values")[0] as { slug: string }).slug).toBe( + "hello-wo", + ); + }); + }); + + describe("update", () => { + const stored = { id: 1, slug: "hello-world", title: "Hello World" }; + + it("leaves the slug alone when the source field changes", async () => { + // The whole point of a slug: a published URL does not move because + // somebody fixed a typo in the title. + const set = await updatePost(stored, { title: "Goodbye World" }); + + expect(set).toEqual({ title: "Goodbye World" }); + expect(set).not.toHaveProperty("slug"); + }); + + it("changes the slug when it is sent explicitly", async () => { + const set = await updatePost(stored, { slug: "Brand New Slug" }); + + expect(set).toEqual({ slug: "brand-new-slug" }); + }); + + it("treats a re-sent slug as no change", async () => { + // Normalised before the diff, so "Hello World" and "hello-world" are + // the same stored value and the write is skipped. + const set = await updatePost(stored, { slug: "Hello World" }); + + expect(set).toBeUndefined(); + }); + + it("rejects a slug that folds to nothing", async () => { + await expect(updatePost(stored, { slug: "???" })).rejects.toThrow( + ContentInputError, + ); + }); + + it("never re-derives from the source", async () => { + const set = await updatePost(stored, { + slug: "explicit-one", + title: "A Totally New Title", + }); + + expect(set).toEqual({ + slug: "explicit-one", + title: "A Totally New Title", + }); + }); + }); + }); }); diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 3b368ec84..dd491d5a7 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -1,19 +1,18 @@ import type { ColumnBaseConfig, SQL } from "drizzle-orm"; import type { PgColumn, - PgTable, PgTableWithColumns, TableConfig, } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import { and, eq } from "drizzle-orm"; -import { alias, getTableConfig } from "drizzle-orm/pg-core"; +import { and, eq, ne, sql } from "drizzle-orm"; import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, ContentCreateInput, + ContentFieldMap, ContentFieldName, ContentFilterInput, ContentOrderableFieldName, @@ -23,9 +22,15 @@ import type { } from "../types"; import { withPagination } from "../../api/lib/with-pagination"; -import { CONTENT_DEFAULT_PAGE_SIZE, CONTENT_OPTIONS_LIMIT } from "../const"; -import { ContentEngineError } from "../errors"; +import { + CONTENT_DEFAULT_PAGE_SIZE, + CONTENT_OPTIONS_LIMIT, + CONTENT_PUBLICATION_FIELDS, + CONTENT_SLUG_DEFAULT_LENGTH, +} from "../const"; +import { ContentEngineError, ContentInputError } from "../errors"; import { orderableColumns } from "../registry"; +import { slugify } from "../slug"; import { buildFilterCondition, buildOrderColumn, @@ -33,6 +38,7 @@ import { diffChangedFields, toColumnValues, } from "./query"; +import { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; /** Display labels for `user` and `relation` values, keyed by field name. */ export type ContentLabels = Record; @@ -75,7 +81,50 @@ export interface ContentUpdateResult { row: ContentSelect; } -export interface ContentService { +export interface ContentPublicationResult { + /** + * `false` when the row was already in that state: no write happened, no event + * was emitted, and nothing needs invalidating. + */ + changed: boolean; + /** + * When the row was first published, or `null` if it never has been. Lifted + * out of `row` because the generated columns are conditional on a type + * parameter that is still open in generic route code. + */ + publishedAt: Date | null; + row: ContentSelect; +} + +export interface ContentPublicationMethods { + /** + * Idempotent. Stamps `publishedAt` on the first `draft -> published` + * transition and never rewrites it. `null` when the row does not exist. + */ + publish: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; + /** Idempotent. Flips `status` only - `publishedAt` is left alone. */ + unpublish: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; +} + +/** + * `publish`/`unpublish` exist only on a content type with publication enabled. + * + * The `never` branch is the same trick `ContentFieldsConstraint` uses for + * reserved system columns: calling `service.publish(...)` on a content type + * without publication is a compile error rather than a runtime surprise. + */ +export type ContentService = ContentServiceBase & + (TDefinition extends { publication: { enabled: true } } + ? ContentPublicationMethods + : Partial, never>>); + +export interface ContentServiceBase { /** Throws a `ZodError` if `values` does not satisfy `schemas.create`. */ create: ( values: ContentCreateInput, @@ -106,84 +155,27 @@ export interface ContentService { ) => Promise | null>; } -interface ReferenceTarget { - /** Aliased, so two relations pointing at the same table can both be joined. */ - aliased: PgTable; - idColumn: PgColumn; - labelColumn: PgColumn; - owner: PgColumn; +interface SlugFieldConfig { + maxLength: number; + name: string; + /** Field the value is derived from when a create payload omits the slug. */ + source: string | undefined; } -const LABEL_PREFIX = "label__"; - -/** - * Turns a joined label column value into display text. Only the shapes a title - * column can actually hold are handled - anything else becomes `null` rather - * than "[object Object]". - */ -const toLabel = (value: unknown): null | string => { - if (value === null || value === undefined) return null; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "bigint") { - return value.toString(); - } - if (value instanceof Date) return value.toISOString(); - - return null; -}; - -/** - * Works out which table and column supply the display label for each - * `user`/`relation` field. - * - * The target comes from the foreign keys Drizzle already resolved on the table, - * so the engine needs no separate table registry - and because the FK thunk is - * evaluated here, circular content type references stay safe. - */ -const resolveReferenceTargets = ( - definition: AnyContentTypeDefinition, - table: PgTableWithColumns, - columns: Record, -): Record => { - const fields = definition.fields; - const byOwnerColumn = new Map( - getTableConfig(table) - .foreignKeys.map(foreignKey => foreignKey.reference()) - .map(reference => [reference.columns[0]?.name, reference]), - ); - - const targets: Record = {}; +const slugFieldsOf = (fields: ContentFieldMap): SlugFieldConfig[] => { + const slugFields: SlugFieldConfig[] = []; for (const [name, fieldValue] of Object.entries(fields)) { - if (fieldValue.kind !== "relation" && fieldValue.kind !== "user") continue; - - const reference = byOwnerColumn.get(name); - if (!reference) { - throw new ContentEngineError( - `Field "${name}" has no foreign key on "${definition.tableName}".`, - { contentTypeId: definition.id }, - ); - } + if (fieldValue.kind !== "slug") continue; - // `user` labels come from the core users table; a relation uses the target - // content type's own `admin.titleField`. - const labelName = - fieldValue.kind === "user" - ? "name" - : (fieldValue.target().admin.titleField ?? "id"); - - const aliased = alias(reference.foreignTable, `${LABEL_PREFIX}${name}`); - const aliasedColumns = aliased as unknown as Record; - - targets[name] = { - aliased, - idColumn: aliasedColumns.id, - labelColumn: aliasedColumns[labelName] ?? aliasedColumns.id, - owner: columns[name], - }; + slugFields.push({ + maxLength: fieldValue.maxLength ?? CONTENT_SLUG_DEFAULT_LENGTH, + name, + source: fieldValue.source, + }); } - return targets; + return slugFields; }; /** @@ -221,11 +213,101 @@ export const createContentService = < // object is the very field map that type is derived from, so this restates // what TypeScript already knows rather than asserting anything new. const fieldNames = Object.keys(fields) as ContentFieldName[]; - const ownColumnNames = ["id", "createdAt", "updatedAt", ...fieldNames]; + const publication = definition.publication.enabled; + const ownColumnNames = [ + "id", + "createdAt", + "updatedAt", + ...(publication ? CONTENT_PUBLICATION_FIELDS : []), + ...fieldNames, + ]; const references = resolveReferenceTargets(definition, table, columns); const searchColumns = definition.admin.list.searchableFields.map( name => columns[name], ); + const slugFields = slugFieldsOf(fields); + + /** + * Normalises a slug and refuses one that folds to nothing. + * + * Nothing random or numeric is appended - `slugify` is deterministic, and + * uniqueness belongs to the unique index, which surfaces a clash as a 409. + */ + const toSlug = ( + slugField: SlugFieldConfig, + value: string, + derived: boolean, + ): string => { + const slug = slugify(value, slugField.maxLength); + if (slug !== "") return slug; + + throw new ContentInputError( + derived + ? `Could not derive "${slugField.name}" from "${slugField.source}". Send "${slugField.name}" explicitly.` + : `Field "${slugField.name}" normalises to an empty slug. Use at least one letter or digit.`, + { contentTypeId }, + ); + }; + + /** + * Fills in and normalises every slug on the way into a create. + * + * A supplied value is normalised rather than trusted, so the same rules apply + * whether the slug came from the caller or from the source field. + */ + const withCreateSlugs = ( + values: Record, + ): Record => { + if (slugFields.length === 0) return values; + + const next = { ...values }; + + for (const slugField of slugFields) { + const supplied = next[slugField.name]; + + if (typeof supplied === "string") { + next[slugField.name] = toSlug(slugField, supplied, false); + continue; + } + + // `assertSlugSources` guarantees a source exists whenever the create + // schema lets the value be omitted, so this is the derived branch. + const source = slugField.source ?? ""; + const from = next[source]; + + next[slugField.name] = toSlug( + slugField, + typeof from === "string" ? from : "", + true, + ); + } + + return next; + }; + + /** + * Normalises the slugs an update actually names, and only those. + * + * A slug is never re-derived here: editing the title of a published article + * must not silently move its URL and 404 every link to it. Sending the slug + * is the only way to change it. + */ + const withUpdateSlugs = ( + patch: Record, + ): Record => { + if (slugFields.length === 0) return patch; + + const next = { ...patch }; + + for (const slugField of slugFields) { + const supplied = next[slugField.name]; + if (typeof supplied !== "string") continue; + + next[slugField.name] = toSlug(slugField, supplied, false); + } + + return next; + }; const db = (options?: ContentServiceOptions): ContentDatabase => options?.tx ?? c.get("db"); @@ -266,7 +348,76 @@ export const createContentService = < return row ?? null; }; - return { + /** Reads the generated column off a raw row, before it is cast to a select. */ + const publishedAtOf = (row: Record): Date | null => { + const value = row.publishedAt; + + return value instanceof Date ? value : null; + }; + + /** + * One conditional UPDATE does the whole job: the `WHERE` clause is what makes + * the transition atomic, so two concurrent publishes cannot both stamp + * `publishedAt`, and no read-then-write race exists. The extra SELECT only + * runs when nothing matched, to tell "already in that state" from "no such + * row" - a distinction the route turns into 200 vs 404. + */ + const transition = async ( + id: number, + options: ContentServiceOptions | undefined, + values: Record, + guard: SQL, + ): Promise | null> => { + const database = db(options); + + const [row] = await database + .update(table) + .set(values) + .where(and(eq(primaryCursor, id), guard)) + .returning(ownSelection()); + + if (row) + return { + changed: true, + publishedAt: publishedAtOf(row), + row: toRow(row), + }; + + const current = await readOne(id, database); + + return current + ? { + changed: false, + publishedAt: publishedAtOf(current), + row: toRow(current), + } + : null; + }; + + const publicationMethods: ContentPublicationMethods = { + publish: async (id, options) => + await transition( + id, + options, + { + // COALESCE, so a republish keeps the original date. `publishedAt` is + // the first-published timestamp and is never rewritten. + publishedAt: sql`coalesce(${columns.publishedAt}, now())`, + status: "published", + }, + ne(columns.status, "published"), + ), + + unpublish: async (id, options) => + await transition( + id, + options, + { status: "draft" }, + eq(columns.status, "published"), + ), + }; + + const service: ContentServiceBase = { create: async (values, options) => { // Generated routes validate too, but a plugin can call the service // directly - and then this is the only thing standing between an @@ -275,7 +426,7 @@ export const createContentService = < const [row] = await db(options) .insert(table) - .values(toColumnValues(fields, parsed)) + .values(toColumnValues(fields, withCreateSlugs(parsed))) .returning(ownSelection()); return toRow(row); @@ -306,6 +457,7 @@ export const createContentService = < // Typed per field for callers; the allowlist check inside stays as // defence in depth for anything that arrives from a query string. filters: filters, + publication, }), buildSearchCondition(searchColumns, query.search), ].filter((item): item is SQL => item !== undefined); @@ -395,7 +547,9 @@ export const createContentService = < update: async (id, values, options) => { // Parsed before the row is even read, so an invalid payload never costs a // query - and never reaches Drizzle. - const patch = schemas.update.parse(values) as Record; + // Normalised before the diff, so re-sending the stored slug in a + // different case counts as no change rather than as a pointless write. + const patch = withUpdateSlugs(schemas.update.parse(values)); const database = db(options); const current = await readOne(id, database); @@ -423,4 +577,15 @@ export const createContentService = < return { changedFields, row: toRow(row) }; }, }; + + // `ContentService` resolves its publication half from + // `TDefinition["publication"]["enabled"]`, which is still a type parameter + // here - so TypeScript cannot check the object against a branch it has not + // picked yet. The runtime flag and the conditional type read the same + // `definition.publication.enabled`, which is what makes the two agree; + // `publication.test-d.ts` asserts it from the outside. + return { + ...service, + ...(publication ? publicationMethods : {}), + } as ContentService; }; diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index c76b84d4f..4235c0d20 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -25,8 +25,13 @@ import type { } from "./types"; import { core_users } from "../../database/users"; +import { CONTENT_PUBLICATION_FIELDS } from "../const"; import { ContentEngineError } from "../errors"; -import { buildContentColumn, buildSystemColumns } from "./column-builders"; +import { + buildContentColumn, + buildPublicationColumns, + buildSystemColumns, +} from "./column-builders"; /** * Wraps a foreign-key thunk so the table it actually points at is checked @@ -139,7 +144,10 @@ export const createContentTable = < const fields = definition.fields; const referenceThunks = references as Record; - const columns: Record = buildSystemColumns(); + const columns: Record = { + ...buildSystemColumns(), + ...(definition.publication.enabled ? buildPublicationColumns() : {}), + }; for (const name of Object.keys(fields)) { columns[name] = buildContentColumn({ @@ -210,6 +218,7 @@ export const contentTableColumns = < "id", "createdAt", "updatedAt", + ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), ...Object.keys(definition.fields), ]; diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 42ad12e9a..fa5207bd3 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -17,6 +17,7 @@ import type { import type { ContentFieldsOf, + ContentPublicationField, ContentSystemField, HasColumnDefault, } from "../types"; @@ -80,9 +81,28 @@ export interface ContentSystemColumnBuilders { updatedAt: NotNull>>; } -export type ContentColumnBuilders = ContentSystemColumnBuilders & { - [K in keyof TFields]: ContentColumnBuilder; -}; +/** `status` and `publishedAt` - added only when publication is enabled. */ +export interface ContentPublicationColumnBuilders { + publishedAt: PgTimestampBuilderInitial; + status: NotNull< + HasDefault< + PgVarcharBuilderInitial + > + >; +} + +type PublicationColumnBuilders = + TPublication extends true + ? ContentPublicationColumnBuilders + : Record; + +export type ContentColumnBuilders< + TFields, + TPublication extends boolean = false, +> = ContentSystemColumnBuilders & + PublicationColumnBuilders & { + [K in keyof TFields]: ContentColumnBuilder; + }; /** * The `pgTable` a content type compiles to. @@ -90,22 +110,35 @@ export type ContentColumnBuilders = ContentSystemColumnBuilders & { * Built with Drizzle's own `BuildColumns`, so `$inferSelect` and `$inferInsert` * come out of the same machinery a hand-written `pgTable` uses. */ -export type ContentTable = PgTableWithColumns<{ - columns: BuildColumns, "pg">; +export type ContentTable< + TName extends string, + TFields, + TPublication extends boolean = false, +> = PgTableWithColumns<{ + columns: BuildColumns< + TName, + ContentColumnBuilders, + "pg" + >; dialect: "pg"; name: TName; schema: undefined; }>; export type ContentTableFor = TDefinition extends { + publication: { enabled: infer TPublication extends boolean }; tableName: infer TName extends string; } - ? ContentTable> + ? ContentTable, TPublication> : never; /** Column name -> Drizzle column, used for allowlisted filters and ordering. */ export type ContentColumnName = - ContentSystemField | (keyof ContentFieldsOf & string); + | ContentSystemField + | (keyof ContentFieldsOf & string) + | (TDefinition extends { publication: { enabled: true } } + ? ContentPublicationField + : never); /** * One thunk per `relation` field, resolving to the target table's `id`. Missing diff --git a/packages/vitnode/src/content/slug.test-d.ts b/packages/vitnode/src/content/slug.test-d.ts new file mode 100644 index 000000000..2b23d29cc --- /dev/null +++ b/packages/vitnode/src/content/slug.test-d.ts @@ -0,0 +1,121 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import type { testPostContentType } from "@/tests/content-fixtures"; + +import type { + ContentCreateInput, + ContentFieldValue, + ContentFilterInput, + ContentSelect, + ContentSlugField, + ContentUpdateInput, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +type Post = typeof testPostContentType; + +describe("field.slug", () => { + describe("descriptor", () => { + it("keeps the source literal", () => { + const sourced = field.slug({ source: "title" }); + + expectTypeOf(sourced.kind).toEqualTypeOf<"slug">(); + expectTypeOf(sourced.source).toEqualTypeOf<"title">(); + }); + + it("is never nullable", () => { + // Not an argument, and not inferable as `true` from anywhere: a row + // without a URL segment cannot be addressed. + expectTypeOf(field.slug().nullable).toEqualTypeOf(); + expectTypeOf( + field.slug({ source: "title" }).nullable, + ).toEqualTypeOf(); + }); + + it("derives `required` from the source", () => { + expectTypeOf(field.slug().required).toEqualTypeOf(); + expectTypeOf( + field.slug({ source: "title" }).required, + ).toEqualTypeOf(); + }); + + it("takes no `required` argument", () => { + // The two could otherwise contradict each other - "you must always send + // it" alongside "derive it for me". + // @ts-expect-error - `required` is a consequence of `source` + field.slug({ required: true, source: "title" }); + }); + + it("takes no `nullable` argument", () => { + // @ts-expect-error - a slug is always NOT NULL + field.slug({ nullable: true }); + }); + + it("carries no default value", () => { + // Nothing downstream should think a slug column has a database default. + expectTypeOf().not.toHaveProperty("defaultValue"); + }); + + it("holds a string", () => { + expectTypeOf< + ContentFieldValue> + >().toEqualTypeOf(); + }); + }); + + describe("on a content type", () => { + it("is a string in the response", () => { + expectTypeOf["slug"]>().toEqualTypeOf(); + }); + + it("is optional in create when it has a source", () => { + assertType>({ category: 1, title: "Hello" }); + assertType>({ + category: 1, + slug: "hello", + title: "Hello", + }); + }); + + it("is required in create when it has none", () => { + const manual = defineContentType({ + id: "test.manual-slug-type", + tableName: "test_manual_slug_types", + fields: { + title: field.text({ required: true }), + slug: field.slug(), + }, + admin: { label: { plural: "Manuals", singular: "Manual" } }, + }); + + expectTypeOf(manual.fields.slug.required).toEqualTypeOf(); + assertType>({ + slug: "hello", + title: "Hello", + }); + // @ts-expect-error - nothing can derive this one + assertType>({ title: "Hello" }); + }); + + it("is optional in update, like every other field", () => { + assertType>({ slug: "moved" }); + }); + + it("is equality-filterable", () => { + expectTypeOf["slug"]>().toEqualTypeOf< + string | undefined + >(); + }); + + it("rejects a non-string value", () => { + assertType>({ + category: 1, + // @ts-expect-error - a slug is text + slug: 12, + title: "Hello", + }); + }); + }); +}); diff --git a/packages/vitnode/src/content/slug.test.ts b/packages/vitnode/src/content/slug.test.ts new file mode 100644 index 000000000..3a264017f --- /dev/null +++ b/packages/vitnode/src/content/slug.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { CONTENT_SLUG_DEFAULT_LENGTH } from "./const"; +import { slugify } from "./slug"; + +describe("slugify", () => { + it.each([ + ["Hello World", "hello-world"], + [" Hello World ", "hello-world"], + ["hello---world", "hello-world"], + ["HELLO WORLD", "hello-world"], + ["already-a-slug", "already-a-slug"], + ["Hello, World! (2026)", "hello-world-2026"], + ["--leading and trailing--", "leading-and-trailing"], + ["under_scores.and.dots", "under-scores-and-dots"], + ["100% pure", "100-pure"], + ["a/b/c", "a-b-c"], + ])("normalises %j to %j", (input, expected) => { + expect(slugify(input)).toBe(expected); + }); + + it.each([ + ["Zażółć gęślą", "zazolc-gesla"], + ["Café Crème", "cafe-creme"], + ["Łódź", "lodz"], + ["Straße", "strasse"], + ["Nærøy", "naeroy"], + ["Þingvellir", "thingvellir"], + ])("transliterates %j to %j", (input, expected) => { + expect(slugify(input)).toBe(expected); + }); + + it("is deterministic", () => { + // Nothing random and nothing numeric is appended - two calls with the same + // input have to agree, or a slug could not be regenerated or asserted on. + expect(slugify("Hello World")).toBe(slugify("Hello World")); + expect(slugify("Hello World")).not.toContain("2"); + }); + + it.each([ + ["", ""], + [" ", ""], + ["!!!", ""], + ["日本語", ""], + ["Привет", ""], + ["🎉", ""], + ["---", ""], + ])("folds %j to an empty slug", (input, expected) => { + // The caller decides what to do about it. There is no random or numeric + // fallback: an unaddressable row is better refused than silently invented. + expect(slugify(input)).toBe(expected); + }); + + describe("length", () => { + it("truncates to the default", () => { + const slug = slugify("a".repeat(500)); + + expect(slug).toHaveLength(CONTENT_SLUG_DEFAULT_LENGTH); + }); + + it("truncates to an explicit maximum", () => { + expect(slugify("hello world", 7)).toBe("hello-w"); + }); + + it("never leaves a trailing dash behind", () => { + // The cut lands exactly on the separator. + expect(slugify("hello world", 6)).toBe("hello"); + expect(slugify("hello world", 5)).toBe("hello"); + }); + + it("keeps a short value untouched", () => { + expect(slugify("hi", 160)).toBe("hi"); + }); + }); +}); diff --git a/packages/vitnode/src/content/slug.ts b/packages/vitnode/src/content/slug.ts new file mode 100644 index 000000000..7d214c229 --- /dev/null +++ b/packages/vitnode/src/content/slug.ts @@ -0,0 +1,59 @@ +import { CONTENT_SLUG_DEFAULT_LENGTH } from "./const"; + +/** + * Letters Unicode normalisation cannot take apart. + * + * `NFD` splits a base letter from its combining marks, which handles nearly + * every accented character - but a stroked or ligature letter is one indivisible + * codepoint, so it survives the pass and would then be dropped as "not a-z". + * `ł` is the one the repo already special-cases in `removeSpecialCharacters`; + * the rest are its immediate neighbours. + */ +const TRANSLITERATIONS: Record = { + æ: "ae", + đ: "d", + ð: "d", + ł: "l", + ø: "o", + œ: "oe", + ß: "ss", + þ: "th", +}; + +/** + * Turns any text into a URL segment: lowercase, ASCII, dash separated. + * + * ```text + * "Hello World" -> "hello-world" + * " Hello World " -> "hello-world" + * "hello---world" -> "hello-world" + * "Zażółć gęślą" -> "zazolc-gesla" + * ``` + * + * Deterministic and pure - the same input always yields the same slug, on every + * machine and in every process. Nothing random and nothing numeric is appended: + * uniqueness is the unique index's job, and a collision surfaces as a 409 rather + * than as a silently different URL. + * + * Returns `""` for text that folds to nothing - CJK, Cyrillic, emoji. Callers + * treat that as a failure rather than inventing a fallback; see the service's + * slug handling. + * + * Not the same thing as `removeSpecialCharacters`, which does not lowercase and + * keeps characters that are illegal in a slug. That one stays where it is, for + * the blog's `friendlyUrl`. + */ +export const slugify = ( + value: string, + maxLength: number = CONTENT_SLUG_DEFAULT_LENGTH, +): string => + value + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase() + .replace(/[æđðłøœßþ]/g, match => TRANSLITERATIONS[match]) + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, maxLength) + // Truncation can land mid-separator, and a trailing dash is not a slug. + .replace(/-+$/, ""); diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index a6b0651aa..2f2d4c236 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,11 +1,20 @@ import type { CONTENT_FILTERABLE_FIELD_KINDS, + CONTENT_PUBLIC_EXPOSABLE_COLUMNS, + CONTENT_PUBLICATION_FIELDS, + CONTENT_PUBLICATION_STATUSES, CONTENT_SYSTEM_FIELDS, } from "./const"; import type { ContentSchemas } from "./schemas"; export type ContentSystemField = (typeof CONTENT_SYSTEM_FIELDS)[number]; +export type ContentPublicationField = + (typeof CONTENT_PUBLICATION_FIELDS)[number]; + +export type ContentPublicationStatus = + (typeof CONTENT_PUBLICATION_STATUSES)[number]; + export type ContentOnDelete = "cascade" | "restrict" | "set null"; /** Flattens intersections so editor tooltips show the real shape. */ @@ -49,6 +58,35 @@ export interface ContentTextField< unique?: boolean; } +/** + * Whether a slug has to be present in the create payload. + * + * Exactly the inverse of "it has a source": with one the engine can always + * derive the value, without one nobody else can. That makes `required` a + * consequence of `source` rather than a second knob, so `field.slug` does not + * take it - the two could otherwise be set to contradict each other. + */ +export type ContentSlugRequired = TSource extends string + ? false + : true; + +/** + * A URL segment: lowercase, ASCII, dash separated, unique across the table. + * + * Never nullable and never defaulted - a row without a slug could not be + * addressed. `source` names the `text` field the value is derived from when a + * create payload leaves it out; an update never re-derives it, so published + * URLs stay put. + */ +export interface ContentSlugField< + TSource extends string | undefined = string | undefined, +> extends ContentFieldShared, false> { + kind: "slug"; + /** `varchar` length and the truncation point. Defaults to 160. */ + maxLength?: number; + source: TSource; +} + export interface ContentTextareaField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, @@ -133,6 +171,7 @@ export type ContentFieldDescriptor = | ContentEnumField | ContentNumberField | ContentRelationField + | ContentSlugField | ContentTextareaField | ContentTextField | ContentUserField; @@ -150,11 +189,17 @@ export type ContentFieldMap = Record; * back to their generic defaults. Mentioning only `kind` still rejects * non-descriptors while leaving literal inference intact, and the reserved * system columns stay a compile error. + * + * `TPublication` extends the same trick to `status` and `publishedAt`, but only + * when the content type opted into publication - a Stage 1 type is free to keep + * declaring its own `status` enum. */ -export type ContentFieldsConstraint = Partial< - Record -> & - Record; +export type ContentFieldsConstraint = + Partial> & + Record & + (TPublication extends true + ? Partial> + : unknown); /** Fields that hold a foreign key to another row. */ export type ContentReferenceField = ContentRelationField | ContentUserField; @@ -231,21 +276,40 @@ export interface ContentAdminLabel { singular: string; } -export interface ContentAdminListConfig { +/** + * `status` and `publishedAt` are addressable in the admin config only once the + * content type opted into publication. + */ +type ContentPublicationColumn = + TPublication extends true ? ContentPublicationField : never; + +export interface ContentAdminListConfig< + TFields = ContentFieldMap, + TPublication extends boolean = boolean, +> { /** Columns shown in the DataTable, in order. Defaults to every field. */ - columns?: (ContentSystemField | keyof TFields)[]; + columns?: ( + ContentPublicationColumn | ContentSystemField | keyof TFields + )[]; defaultOrder?: "asc" | "desc"; - defaultOrderBy?: ContentSystemField | keyof TFields; - /** Allowlist for `orderBy`. System columns are always allowed. */ + defaultOrderBy?: + ContentPublicationColumn | ContentSystemField | keyof TFields; + /** + * Allowlist for `orderBy`. System columns - and the publication columns when + * enabled - are always allowed and need no entry here. + */ orderableFields?: (keyof TFields)[]; /** Only `text` and `textarea` fields may be searched. */ searchableFields?: (keyof TFields)[]; } -export interface ContentAdminConfig { +export interface ContentAdminConfig< + TFields = ContentFieldMap, + TPublication extends boolean = boolean, +> { form?: { fields?: (keyof TFields)[] }; label: ContentAdminLabel; - list?: ContentAdminListConfig; + list?: ContentAdminListConfig; navigation?: { enabled?: boolean }; /** * Staff permission module name. Defaults to a slug of `label.plural`, e.g. @@ -285,16 +349,24 @@ export interface ResolvedContentAdminConfig { * A declared index and a generated one covering the same columns collapse into * a single index; see `resolveContentIndexes`. */ -export interface ContentIndexInput { +export interface ContentIndexInput< + TFields = ContentFieldMap, + TPublication extends boolean = boolean, +> { /** Defaults to `

__idx`, or `_key` when unique. */ name?: string; on: [ - ContentSystemField | (keyof TFields & string), - ...(ContentSystemField | (keyof TFields & string))[], + ContentIndexColumn, + ...ContentIndexColumn[], ]; unique?: boolean; } +type ContentIndexColumn = + | ContentPublicationColumn + | ContentSystemField + | (keyof TFields & string); + /** * Stored shape. Non-generic for the same reason as * {@link ResolvedContentAdminConfig} - `keyof TFields` would make @@ -317,13 +389,128 @@ export interface ResolvedContentIndex { unique: boolean; } +// --------------------------------------------------------------------------- +// Publication +// --------------------------------------------------------------------------- + +/** + * Opts a content type into the draft/published lifecycle. + * + * `enabled` is literal `true` rather than `boolean` so the flag survives + * inference: every conditional in this file keys off `{ enabled: true }`, and a + * widened `boolean` would silently resolve to the disabled branch. + */ +export interface ContentPublicationConfig { + enabled: true; +} + +export interface ResolvedContentPublicationConfig< + TEnabled extends boolean = boolean, +> { + enabled: TEnabled; +} + +/** + * The two generated columns, present only when publication is enabled. + * + * `AnyContentTypeDefinition` carries `enabled: boolean`, which does not extend + * `true`, so the erased definition resolves to the empty branch - generic code + * sees a row without them, exactly as it did in Stage 1. + */ +type ContentPublicationColumns = TDefinition extends { + publication: { enabled: true }; +} + ? { publishedAt: Date | null; status: ContentPublicationStatus } + : Record; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Everything `publicApi.fields` may name: declared fields plus a few columns. */ +export type ContentPublicExposableField = + (typeof CONTENT_PUBLIC_EXPOSABLE_COLUMNS)[number] | (keyof TFields & string); + +/** + * Opts a content type into a generated, read-only public API. + * + * Requires `publication: { enabled: true }` and exactly one exposed slug field, + * both checked at definition time. Enabling publication on its own never makes + * anything public - this block is the only thing that does. + * + * `enabled` is literal `true` for the same reason publication's is: every + * conditional keys off it, and a widened `boolean` would silently resolve to + * "no public API". + */ +export interface ContentPublicApiConfig { + defaultOrder?: "asc" | "desc"; + /** Defaults to `publishedAt`. Must be orderable. */ + defaultOrderBy?: "publishedAt" | TField; + enabled: true; + /** The allowlist. There is no wildcard - a new field is private until listed. */ + fields: readonly [TField, ...TField[]]; + /** Equality filters the list route accepts. Defaults to none. */ + filterableFields?: readonly TField[]; + /** Columns `orderBy` accepts, besides `publishedAt`. Defaults to none. */ + orderableFields?: readonly TField[]; + /** One lowercase URL segment, e.g. `articles`. Never `admin`. */ + path: string; + /** Columns `search` scans. Defaults to none. */ + searchableFields?: readonly TField[]; +} + +/** + * `publicApi` after `defineContentType` has filled in every default. + * + * Generic over the exposed field-name *union* and nothing else. `keyof TFields` + * here would make `ContentTypeDefinition` invariant and break assignability to + * `AnyContentTypeDefinition` - the exact trap `ResolvedContentAdminConfig` + * documents. A `TField[]` stays covariant, so `("title" | "slug")[]` is still a + * `string[]`. + */ +export interface ResolvedContentPublicApiConfig< + TField extends string = string, + TEnabled extends boolean = boolean, +> { + defaultOrder: "asc" | "desc"; + defaultOrderBy: string; + enabled: TEnabled; + fields: TField[]; + filterableFields: string[]; + orderableFields: string[]; + path: string; + searchableFields: string[]; + /** The exposed slug field the detail route resolves by. */ + slugField: string; +} + // --------------------------------------------------------------------------- // Definition // --------------------------------------------------------------------------- +/** + * A content type that actually has a generated public API. + * + * The erased `AnyContentTypeDefinition` carries `enabled: boolean`, so it also + * describes a content type with no public API at all - one whose `publicApi.path` + * is the empty string. Anything that builds a public URL takes this instead, so + * passing the wrong content type is a compile error rather than a request to + * `/api/{pluginId}/content//`. + * + * An intersection rather than a fifth type argument: `enabled` is the only + * parameter a caller of the public read layer needs pinned, and narrowing just + * that one keeps every concrete definition assignable. + */ +export type PublicContentTypeDefinition = AnyContentTypeDefinition & { + publicApi: { enabled: true }; +}; + export interface ContentTypeDefinition< TId extends string = string, TFields = ContentFieldMap, + TPublication extends boolean = boolean, + TPublicField extends string = string, + TPublicEnabled extends boolean = boolean, > { admin: ResolvedContentAdminConfig; fields: TFields; @@ -332,8 +519,18 @@ export interface ContentTypeDefinition< indexes: ResolvedContentIndex[]; /** Derived from `admin.permissionModule` or `admin.label.plural`. */ permissionModule: string; + publicApi: ResolvedContentPublicApiConfig; + publication: ResolvedContentPublicationConfig; /** Zod schemas generated from `fields`. */ - schemas: ContentSchemas>; + schemas: ContentSchemas< + ContentTypeDefinition< + TId, + TFields, + TPublication, + TPublicField, + TPublicEnabled + > + >; tableName: string; } @@ -347,7 +544,7 @@ export type ContentFieldsOf = TDefinition extends { : never; export type ContentSelect = Prettify< - { + ContentPublicationColumns & { [K in keyof ContentFieldsOf]: ContentFieldValue< ContentFieldsOf[K] >; @@ -401,12 +598,20 @@ export type FilterableContentFieldName = FieldNamesOfKind< FilterableContentFieldKind >; -/** Equality filters accepted by `service.findMany`, one key per filterable field. */ -export type ContentFilterInput = Partial<{ - [K in FilterableContentFieldName]: ContentFieldInput< - ContentFieldsOf[K] - >; -}>; +/** + * Equality filters accepted by `service.findMany`, one key per filterable + * field - plus `status` once publication is enabled, which is a generated + * column rather than a declared field. + */ +export type ContentFilterInput = Partial< + (TDefinition extends { publication: { enabled: true } } + ? { status: ContentPublicationStatus } + : Record) & { + [K in FilterableContentFieldName]: ContentFieldInput< + ContentFieldsOf[K] + >; + } +>; /** * Columns `service.findMany` may order by. @@ -415,12 +620,110 @@ export type ContentFilterInput = Partial<{ * is stored on the *resolved* (non-generic) admin config, so the configured * array is not recoverable as a type. Every field name is accepted here, and * the narrower runtime allowlist rejects the ones that were not configured. + * + * The generated publication columns are part of that allowlist at runtime - + * `orderableColumns` appends them, and the generated route's `orderBy` enum + * includes them - so they belong here too, but only for a content type that + * actually opted in. */ export type ContentOrderableFieldName = - ContentFieldName | ContentSystemField; + | ContentFieldName + | ContentSystemField + | (TDefinition extends { publication: { enabled: true } } + ? ContentPublicationField + : never); /** Fields with a picker - the only ones `service.options` can enumerate. */ export type ContentReferenceFieldName = FieldNamesOfKind< TDefinition, "relation" | "user" >; + +// --------------------------------------------------------------------------- +// Public projection +// --------------------------------------------------------------------------- + +/** + * How an exposed `relation` comes back: an identifier, and nothing else. + * + * Deliberately not the related row, and deliberately **not a label**. The + * obvious label is the target's `admin.titleField`, but that is administrative + * metadata: it may name a field the target does not expose publicly, the target + * may have no `publicApi` at all, and the row it is read from may itself be a + * draft. Publishing an internal name because two content types are related is + * not a decision one allowlist should make on behalf of another. + * + * An identifier is enough to fetch the related row through its own public API, + * which is the layer that decides what it is willing to say. Configurable + * public relation labels are a later stage; deep nesting and arbitrary + * population are the point at which a REST projection turns into GraphQL, and a + * hand-written route is the better answer to that. + */ +export interface ContentPublicRelation { + id: number; +} + +/** The exposed field names of one content type, read off its resolved config. */ +export type ContentPublicFieldName = TDefinition extends { + publicApi: { fields: (infer TField extends string)[] }; +} + ? TField + : never; + +type ContentPublicValue = TName extends "id" + ? number + : TName extends "createdAt" | "updatedAt" + ? Date + : TName extends "publishedAt" + ? Date | null + : TName extends keyof TFields + ? TFields[TName] extends { kind: "relation" } + ? TFields[TName] extends { nullable: true } + ? ContentPublicRelation | null + : ContentPublicRelation + : ContentFieldValue + : never; + +/** + * One public row: exactly the allowlisted fields, and not one key more. + * + * A field the content type declares but `publicApi.fields` does not name is + * absent from this type *and* absent from the generated `SELECT`, so it never + * leaves Postgres. Adding a field to the content type does not add it here. + */ +export type ContentPublicSelect = Prettify<{ + [K in ContentPublicFieldName]: ContentPublicValue< + ContentFieldsOf, + K + >; +}>; + +/** + * A row in a public list. + * + * The same projection as the detail response today - there is no list-only or + * detail-only field. Both names exist so a route signature says which one it + * means, and so the two can diverge later without a rename. + */ +export type ContentPublicListRow = + ContentPublicSelect; + +/** + * Equality filters the public service accepts. + * + * Exposed *and* filterable - a private field can never be filtered on, which is + * what stops a filter being used to probe a column the response omits. Like the + * admin equivalent this is one step wider than the runtime: the configured + * `publicApi.filterableFields` array is not recoverable as a type, so the + * narrower check is the runtime allowlist. + */ +export type ContentPublicFilterInput = Partial<{ + [ + K in ContentPublicFieldName & + FilterableContentFieldName + ]: ContentFieldInput[K]>; +}>; + +/** Columns the public list may be ordered by. */ +export type ContentPublicOrderableFieldName = + "publishedAt" | ContentPublicFieldName; diff --git a/packages/vitnode/src/lib/fetcher/raw.ts b/packages/vitnode/src/lib/fetcher/raw.ts index c3610564d..a93ab678e 100644 --- a/packages/vitnode/src/lib/fetcher/raw.ts +++ b/packages/vitnode/src/lib/fetcher/raw.ts @@ -12,7 +12,18 @@ export interface RawApiFetchArgs { method: string; /** Module path under the plugin, e.g. `admin/content/articles`. */ module: string; - options?: Omit; + options?: Omit & { + /** + * Next's own `fetch` extension, for cache tags and revalidation. + * + * Spelled out rather than inherited: Next augments the global `RequestInit` + * from its own type declarations, and `@vitnode/core` compiles in contexts + * where those are not loaded - `apps/api` is plain Node. Declaring the + * shape here keeps every caller on the shared fetcher instead of reaching + * for `fetch` directly to get one property. + */ + next?: { revalidate?: false | number; tags?: string[] }; + }; params?: Record; /** Route path within the module, e.g. `/` or `/{id}`. */ path: string; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 8946ada3d..b6c3bf39b 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -398,7 +398,8 @@ "created_at": "Created", "updated_at": "Updated", "actions": "Actions", - "empty_value": "—" + "empty_value": "—", + "published_at": "Published" }, "create": { "title": "Create {name}", @@ -418,6 +419,27 @@ "confirm": "Yes, delete it", "success": "{name} has been deleted." }, + "publish": { + "title": "Publish {name}", + "desc": "Publish ? It becomes visible to everyone.", + "confirm": "Yes, publish it", + "success": "{name} has been published.", + "action": "Publish" + }, + "unpublish": { + "title": "Unpublish {name}", + "desc": "Unpublish ? It disappears from public pages but is not deleted.", + "confirm": "Yes, unpublish it", + "success": "{name} has been unpublished.", + "action": "Unpublish" + }, + "status": { + "label": "Status", + "draft": "Draft", + "published": "Published", + "published_on": "Published on {date}", + "never_published": "Not published yet" + }, "form": { "relation": { "placeholder": "Select…", @@ -433,7 +455,8 @@ "can_view": "View list", "can_create": "Create", "can_edit": "Edit", - "can_delete": "Delete" + "can_delete": "Delete", + "can_publish": "Publish and unpublish" }, "errors": { "not_found": "This record no longer exists. Refresh the list and try again.", diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 2a993cedc..162879350 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -17,6 +17,10 @@ export const testCategoryContentType = defineContentType({ }, }); +/** + * Stage 1 shape, kept deliberately unchanged: it declares its own `status` and + * `publishedAt` fields, so it doubles as the backward-compatibility fixture. + */ export const testArticleContentType = defineContentType({ id: "test.article", tableName: "test_articles", @@ -50,3 +54,46 @@ export const testArticleContentType = defineContentType({ }, }, }); + +/** + * The Stage 2 shape: `status` and `publishedAt` come from `publication`, and + * the URL segment from a `slug` field derived from the title. + */ +export const testPostContentType = defineContentType({ + id: "test.post", + tableName: "test_posts", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + views: field.number({ integer: true, min: 0, defaultValue: 0 }), + author: field.user(), + category: field.relation({ + required: true, + onDelete: "restrict", + target: () => testCategoryContentType, + }), + }, + publication: { enabled: true }, + // `views` and `author` are deliberately absent from `fields`: they are the + // "a private field never leaves Postgres" assertion in the public tests. + publicApi: { + enabled: true, + path: "posts", + fields: ["title", "slug", "excerpt", "category", "publishedAt"], + searchableFields: ["title", "excerpt"], + orderableFields: ["publishedAt", "title"], + filterableFields: ["category"], + defaultOrderBy: "publishedAt", + defaultOrder: "desc", + }, + admin: { + label: { plural: "Test Posts", singular: "Test Post" }, + titleField: "title", + list: { + searchableFields: ["title", "excerpt"], + orderableFields: ["title"], + defaultOrderBy: "publishedAt", + }, + }, +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index 3a9aaf9e7..f83e51da4 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -2,6 +2,7 @@ // `create-action`/`edit-action`, which are already client entries. Declaring // it again would make this a nested client entry, and `next/dynamic` cannot // resolve one from inside a published package - the dialog spins forever. +import { CircleCheckIcon, FileClockIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import React from "react"; import { toast } from "sonner"; @@ -9,7 +10,9 @@ import { toast } from "sonner"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; +import { DateFormat } from "@/components/date-format"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; +import { Badge } from "@/components/ui/badge"; import { useDialog } from "@/components/ui/dialog"; import { buildFormSchemaFromSpec, @@ -26,6 +29,43 @@ import { loadContentOptionsAction, } from "./mutation-api.server"; +/** + * A read-only line saying where the row is in the lifecycle. + * + * Read-only on purpose: `status` and `publishedAt` are not in the form schema, + * and the one place that moves them is the table's publish action. Two + * competing mutation paths in one dialog is how a form ends up fighting its own + * optimistic state. + */ +const PublicationStatus = ({ + publishedAt, + status, +}: { + publishedAt: unknown; + status: unknown; +}) => { + const t = useTranslations("core.content.status"); + const published = status === "published"; + const date = typeof publishedAt === "string" ? new Date(publishedAt) : null; + + return ( +
+ {t("label")} + + {published ? ( + + ) : ( + + )} + {published ? t("published") : t("draft")} + + + {date ? : t("never_published")} + +
+ ); +}; + export interface ContentFormProps { /** Existing values when editing; absent when creating. */ data?: Record & { id: number }; @@ -34,6 +74,8 @@ export interface ContentFormProps { string, (props: ItemAutoFormComponentProps) => React.ReactNode >; + /** Whether the content type has the draft/published lifecycle. */ + publication?: boolean; /** The content type's singular label, used in the success toast. */ singular: string; spec: ContentFormSpec; @@ -44,6 +86,7 @@ export interface ContentFormProps { export const ContentForm = ({ data, fieldOverrides = {}, + publication = false, singular, spec, title, @@ -101,38 +144,47 @@ export const ContentForm = ({ }; return ( - ({ - id: fieldSpec.name, - - // MUST NOT be async: `AutoForm` calls this to get an element, and an - // async function hands it a fresh Promise every render - React 19 - // suspends on promise children, so the dialog spins forever. - // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above - component: props => { - const override = fieldOverrides[fieldSpec.name]; - if (override) return override(props); - - return ( - - await loadContentOptionsAction( - spec.contentTypeId, - field, - search, - ) - } - spec={fieldSpec} - {...props} - /> - ); - }, - }))} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - children: t(data ? "edit.submit" : "create.submit"), - }} - /> + <> + {publication && data ? ( + + ) : null} + + ({ + id: fieldSpec.name, + + // MUST NOT be async: `AutoForm` calls this to get an element, and an + // async function hands it a fresh Promise every render - React 19 + // suspends on promise children, so the dialog spins forever. + // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above + component: props => { + const override = fieldOverrides[fieldSpec.name]; + if (override) return override(props); + + return ( + + await loadContentOptionsAction( + spec.contentTypeId, + field, + search, + ) + } + spec={fieldSpec} + {...props} + /> + ); + }, + }))} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + children: t(data ? "edit.submit" : "create.submit"), + }} + /> + ); }; diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts index f9868cf6b..322d1d131 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts @@ -3,9 +3,14 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; +import type { ContentInvalidationMode } from "@/content/next/revalidate.server"; +import type { AnyContentTypeDefinition } from "@/content/types"; + import { findFrontendContentType } from "@/content/admin/config"; import { contentApiFetch } from "@/content/admin/fetch.server"; +import { isContentPubliclyVisible } from "@/content/cache"; import { CONTENT_OPTIONS_LIMIT } from "@/content/const"; +import { revalidateContent } from "@/content/next/revalidate.server"; /** * The generic content screen ships from core, so its cached page path is the @@ -29,6 +34,111 @@ const resolve = (contentTypeId: string) => { return entry; }; +/** Anything the generated routes return: an identifier plus the row's fields. */ +const zodRow = z.object({ id: z.number() }).loose(); + +const zodPublicationResult = z.object({ changed: z.boolean(), row: zodRow }); + +type ContentRow = z.infer; + +/** + * Where a row sits relative to the public API, read off a mutation response. + * + * `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. + */ +const publicStateOf = ( + definition: AnyContentTypeDefinition, + row?: ContentRow, +) => { + const slug = row?.[definition.publicApi.slugField]; + + return { + isPublic: isContentPubliclyVisible({ + publishedAt: row?.publishedAt as Date | null | string | undefined, + status: row?.status as string | undefined, + }), + slug: typeof slug === "string" ? slug : "", + }; +}; + +/** + * Reads a row *before* a write, so an update knows the slug it is about to + * replace. + * + * Deliberately a read rather than a guess: the generated `PUT` returns the new + * row, and by then the old URL is gone. 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 invalidate 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 - it is skipped + * entirely for everything else. + */ +const readRow = async ( + definition: AnyContentTypeDefinition, + pluginId: string, + id: number, +): Promise => { + if (!definition.publicApi.enabled) return undefined; + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}`, + pluginId, + schema: zodRow, + }); + + return result.data; +}; + +type PublicState = ReturnType; + +/** + * 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 simply a few seconds out of date, and keeping the cache + * warm is worth more than that. + * + * Everything else *removes* public reachability. An unpublish, a delete or a + * slug change must not serve the old response even once, so those expire + * immediately. + */ +const modeFor = ( + previous: PublicState, + current: PublicState, +): ContentInvalidationMode => + previous.isPublic && current.isPublic && previous.slug === current.slug + ? "stale-while-revalidate" + : "immediate"; + +/** Expires the public cache entries this mutation actually affected. */ +const invalidate = ( + definition: AnyContentTypeDefinition, + id: number, + before: ContentRow | undefined, + after: ContentRow | undefined, +): void => { + if (!definition.publicApi.enabled) return; + + const previous = publicStateOf(definition, before); + const current = publicStateOf(definition, after); + + revalidateContent( + { + contentTypeId: definition.id, + id, + isPublic: current.isPublic, + // Both, so a slug change stops the old URL and starts the new one. + slugs: [previous.slug, current.slug], + wasPublic: previous.isPublic, + }, + { mode: modeFor(previous, current) }, + ); +}; + export const createContentAction = async ( contentTypeId: string, values: Record, @@ -40,6 +150,7 @@ export const createContentAction = async ( definition, method: "post", pluginId, + schema: zodRow, }); if (result.status !== 201) { @@ -47,6 +158,9 @@ export const createContentAction = async ( } revalidatePath(CONTENT_PAGE_PATH, "page"); + // A new row starts as a draft, so this normally invalidates nothing at all - + // it is computed rather than assumed, so the rule holds if that changes. + invalidate(definition, result.data?.id ?? 0, undefined, result.data); return {}; }; @@ -58,12 +172,16 @@ export const editContentAction = async ( ): Promise => { const { definition, pluginId } = resolve(contentTypeId); + // Before the write, so a slug change can invalidate the URL it replaced. + const before = await readRow(definition, pluginId, id); + const result = await contentApiFetch({ body: values, definition, method: "put", path: `/${id}`, pluginId, + schema: zodRow, }); if (result.status !== 200) { @@ -71,6 +189,7 @@ export const editContentAction = async ( } revalidatePath(CONTENT_PAGE_PATH, "page"); + invalidate(definition, id, before, result.data); return {}; }; @@ -86,6 +205,7 @@ export const deleteContentAction = async ( method: "delete", path: `/${id}`, pluginId, + schema: zodRow, }); if (result.status !== 200) { @@ -94,9 +214,91 @@ export const deleteContentAction = async ( revalidatePath(CONTENT_PAGE_PATH, "page"); + if (definition.publicApi.enabled) { + // A delete is final, so the question is "was it ever published?" rather + // than "was it live a second ago". `publishedAt` survives an unpublish, and + // expiring a URL that is now gone forever costs nothing. + revalidateContent( + { + contentTypeId: definition.id, + id, + isPublic: false, + slugs: [publicStateOf(definition, result.data).slug], + wasPublic: result.data?.publishedAt != null, + }, + // The row is gone. Serving its cached response one more time would be a + // 200 for something that no longer exists. + { mode: "immediate" }, + ); + } + + return {}; +}; + +/** + * Publishing and unpublishing share one shape, so they share one call. + * + * Both routes are idempotent: publishing something already published is a 200 + * with `changed: false`, not an error. The button therefore never has to guard + * against a double click, and a stale row in the table resolves itself. + */ +const publicationAction = async ( + contentTypeId: string, + id: number, + action: "publish" | "unpublish", +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "post", + path: `/${id}/${action}`, + pluginId, + schema: zodPublicationResult, + }); + + if (result.status !== 200) { + return { error: result.error ?? "", status: result.status }; + } + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + // A no-op transitioned nothing, so nothing public went stale. Expiring a tag + // on every button press would throw away a warm cache for free. + if (result.data?.changed && definition.publicApi.enabled) { + const { isPublic, slug } = publicStateOf(definition, result.data.row); + + revalidateContent( + { + contentTypeId: definition.id, + id, + isPublic, + slugs: [slug], + // A real transition flips visibility by definition. + wasPublic: !isPublic, + }, + // Both directions are immediate. Unpublishing must not leave the post + // readable for one more request, and publishing should be visible the + // moment the success toast appears rather than on the request after it. + { mode: "immediate" }, + ); + } + return {}; }; +export const publishContentAction = async ( + contentTypeId: string, + id: number, +): Promise => + await publicationAction(contentTypeId, id, "publish"); + +export const unpublishContentAction = async ( + contentTypeId: string, + id: number, +): Promise => + await publicationAction(contentTypeId, id, "unpublish"); + const zodOptions = z.object({ items: z.array(z.object({ label: z.string(), value: z.number() })), }); diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts new file mode 100644 index 000000000..3b682de32 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts @@ -0,0 +1,333 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AnyContentTypeDefinition } from "@/content/types"; + +import { + testCategoryContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +interface CacheCall { + fn: "revalidateTag" | "updateTag"; + tag: string; +} + +const cacheCalls: CacheCall[] = []; +const fetches: { method: string; path?: string }[] = []; +let responses: { data?: unknown; status: number }[] = []; +let definition: AnyContentTypeDefinition = testPostContentType; + +// The real `revalidate.server` runs: what this suite is about is which Next +// cache function a given mutation ends up calling, so mocking the layer in +// between would test the mock. +vi.mock("server-only", () => ({})); + +vi.mock("next/cache", () => ({ + revalidatePath: () => undefined, + revalidateTag: (tag: string) => { + cacheCalls.push({ fn: "revalidateTag", tag }); + }, + updateTag: (tag: string) => { + cacheCalls.push({ fn: "updateTag", tag }); + }, +})); + +vi.mock("@/content/admin/config", () => ({ + findFrontendContentType: () => ({ + definition, + pluginId: "@vitnode/example", + registration: {}, + }), +})); + +vi.mock("@/content/admin/fetch.server", () => ({ + contentApiFetch: async ({ + method, + path, + }: { + method: string; + path?: string; + }) => { + await Promise.resolve(); + fetches.push({ method, path }); + + return responses.shift() ?? { status: 500 }; + }, +})); + +const { + createContentAction, + deleteContentAction, + editContentAction, + publishContentAction, + unpublishContentAction, +} = await import("./mutation-api.server"); + +const past = new Date(Date.now() - 60_000).toISOString(); + +const LIST = "content:test.post:list"; +const ITEM = "content:test.post:item:7"; +const slugTag = (slug: string) => `content:test.post:slug:${slug}`; + +const tags = () => cacheCalls.map(call => call.tag); +/** Which Next cache API was used - `updateTag` is the immediate one. */ +const mode = () => [...new Set(cacheCalls.map(call => call.fn))]; + +beforeEach(() => { + cacheCalls.length = 0; + fetches.length = 0; + responses = []; + definition = testPostContentType; +}); + +describe("edit", () => { + it("reads the row before writing, so a slug change is knowable", async () => { + responses = [ + { data: { id: 7, publishedAt: past, slug: "old", status: "published" } }, + { data: { id: 7, publishedAt: past, slug: "new", status: "published" } }, + ].map(item => ({ ...item, status: 200 })); + + await editContentAction("test.post", 7, { title: "Hello" }); + + expect(fetches.map(item => item.method)).toEqual(["get", "put"]); + expect(tags()).toEqual([LIST, ITEM, slugTag("old"), slugTag("new")]); + }); + + it("expires a moved slug immediately", async () => { + // The old URL has to stop resolving now. Serving it stale even once would + // be a 200 for an address the row no longer answers to. + responses = [ + { data: { id: 7, publishedAt: past, slug: "old", status: "published" } }, + { data: { id: 7, publishedAt: past, slug: "new", status: "published" } }, + ].map(item => ({ ...item, status: 200 })); + + await editContentAction("test.post", 7, { title: "Hello" }); + + expect(mode()).toEqual(["updateTag"]); + }); + + it("expires the current slug when it did not move", async () => { + responses = [ + { data: { id: 7, publishedAt: past, slug: "same", status: "published" } }, + { data: { id: 7, publishedAt: past, slug: "same", status: "published" } }, + ].map(item => ({ ...item, status: 200 })); + + await editContentAction("test.post", 7, { title: "Hello" }); + + expect(tags()).toEqual([LIST, ITEM, slugTag("same")]); + }); + + it("lets an ordinary published edit go stale-while-revalidate", async () => { + // Still published, still the same URL: the response that may be served one + // more time is one a visitor is allowed to see, so the cache stays warm. + responses = [ + { data: { id: 7, publishedAt: past, slug: "same", status: "published" } }, + { data: { id: 7, publishedAt: past, slug: "same", status: "published" } }, + ].map(item => ({ ...item, status: 200 })); + + await editContentAction("test.post", 7, { title: "Hello" }); + + expect(mode()).toEqual(["revalidateTag"]); + }); + + it("touches nothing when the row is a draft before and after", async () => { + responses = [ + { data: { id: 7, publishedAt: null, slug: "draft", status: "draft" } }, + { data: { id: 7, publishedAt: null, slug: "draft", status: "draft" } }, + ].map(item => ({ ...item, status: 200 })); + + await editContentAction("test.post", 7, { title: "Hello" }); + + expect(cacheCalls).toEqual([]); + }); + + it("does not read anything extra for a content type with no public API", async () => { + // The pre-write read exists only to learn the old slug, so it is skipped + // where there are no public tags to expire. + definition = testCategoryContentType; + responses = [{ data: { id: 7 }, status: 200 }]; + + await editContentAction("test.category", 7, { title: "Hello" }); + + expect(fetches.map(item => item.method)).toEqual(["put"]); + expect(cacheCalls).toEqual([]); + }); +}); + +describe("publish and unpublish", () => { + it("expires the list, the item and the slug on publish", async () => { + responses = [ + { + data: { + changed: true, + row: { id: 7, publishedAt: past, slug: "hello", status: "published" }, + }, + status: 200, + }, + ]; + + await publishContentAction("test.post", 7); + + expect(tags()).toEqual([LIST, ITEM, slugTag("hello")]); + }); + + it("makes a publish visible immediately", async () => { + // Read-your-own-writes: the post should be there when the success toast is. + responses = [ + { + data: { + changed: true, + row: { id: 7, publishedAt: past, slug: "hello", status: "published" }, + }, + status: 200, + }, + ]; + + await publishContentAction("test.post", 7); + + expect(mode()).toEqual(["updateTag"]); + }); + + it("expires the same three on unpublish", async () => { + responses = [ + { + data: { + changed: true, + row: { id: 7, publishedAt: past, slug: "hello", status: "draft" }, + }, + status: 200, + }, + ]; + + await unpublishContentAction("test.post", 7); + + expect(tags()).toEqual([LIST, ITEM, slugTag("hello")]); + }); + + it("takes an unpublished row down immediately", async () => { + // The one case stale-while-revalidate would get flatly wrong: a visitor + // reading a post that was just taken off the internet. + responses = [ + { + data: { + changed: true, + row: { id: 7, publishedAt: past, slug: "hello", status: "draft" }, + }, + status: 200, + }, + ]; + + await unpublishContentAction("test.post", 7); + + expect(mode()).toEqual(["updateTag"]); + }); + + it("expires nothing for a no-op", async () => { + // Publishing something already published transitioned nothing, so nothing + // public went stale - a double click must not throw away a warm cache. + responses = [ + { + data: { + changed: false, + row: { id: 7, publishedAt: past, slug: "hello", status: "published" }, + }, + status: 200, + }, + ]; + + await publishContentAction("test.post", 7); + + expect(cacheCalls).toEqual([]); + }); +}); + +describe("delete", () => { + it("expires everything for a row that was published", async () => { + responses = [ + { + data: { id: 7, publishedAt: past, slug: "hello", status: "published" }, + status: 200, + }, + ]; + + await deleteContentAction("test.post", 7); + + expect(tags()).toEqual([LIST, ITEM, slugTag("hello")]); + }); + + it("expires a deleted row immediately", async () => { + // The row is gone. A stale 200 would be a response for something that no + // longer exists. + responses = [ + { + data: { id: 7, publishedAt: past, slug: "hello", status: "published" }, + status: 200, + }, + ]; + + await deleteContentAction("test.post", 7); + + expect(mode()).toEqual(["updateTag"]); + }); + + it("expires everything for a row that was published and then unpublished", async () => { + // `publishedAt` survives an unpublish, and a delete is final - expiring a + // URL that is now gone forever costs nothing. + responses = [ + { + data: { id: 7, publishedAt: past, slug: "hello", status: "draft" }, + status: 200, + }, + ]; + + await deleteContentAction("test.post", 7); + + expect(tags()).toEqual([LIST, ITEM, slugTag("hello")]); + }); + + it("expires nothing for a row that never went live", async () => { + responses = [ + { + data: { id: 7, publishedAt: null, slug: "hello", status: "draft" }, + status: 200, + }, + ]; + + await deleteContentAction("test.post", 7); + + expect(cacheCalls).toEqual([]); + }); +}); + +describe("create", () => { + it("expires nothing, because a new row is a draft", async () => { + responses = [ + { + data: { id: 7, publishedAt: null, slug: "hello", status: "draft" }, + status: 201, + }, + ]; + + await createContentAction("test.post", { title: "Hello" }); + + expect(cacheCalls).toEqual([]); + }); +}); + +describe("failures", () => { + it("expires nothing when the write failed", async () => { + responses = [ + { + data: { id: 7, publishedAt: past, slug: "hello", status: "published" }, + status: 200, + }, + { status: 409 }, + ]; + + const result = await editContentAction("test.post", 7, { slug: "taken" }); + + expect(result.status).toBe(409); + expect(cacheCalls).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/publish-action.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/publish-action.test.tsx new file mode 100644 index 000000000..aadf05e8f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/publish-action.test.tsx @@ -0,0 +1,239 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PublishContentAction } from "./publish-action"; + +const publish = vi.fn(); +const unpublish = vi.fn(); +const success = vi.fn(); +const error = vi.fn(); +let canPublish = true; + +vi.mock("next-intl", () => ({ + useTranslations: (namespace: string) => { + const t = (key: string) => `${namespace}.${key}`; + t.rich = (key: string) => `${namespace}.${key}`; + + return t; + }, +})); + +vi.mock("sonner", () => ({ + toast: { + error: (...args: unknown[]) => error(...args), + success: (...args: unknown[]) => success(...args), + }, +})); + +vi.mock("@/components/staff-permission/provider", () => ({ + useAdminStaffPermission: () => canPublish, +})); + +vi.mock("./mutation-api.server", () => ({ + publishContentAction: async (...args: unknown[]) => { + await Promise.resolve(); + + return publish(...args) as unknown; + }, + unpublishContentAction: async (...args: unknown[]) => { + await Promise.resolve(); + + return unpublish(...args) as unknown; + }, +})); + +const renderAction = (status: string) => + render( + , + ); + +beforeEach(() => { + canPublish = true; + publish.mockReset().mockResolvedValue({}); + unpublish.mockReset().mockResolvedValue({}); + success.mockReset(); + error.mockReset(); +}); + +describe("PublishContentAction", () => { + it("offers Publish for a draft", () => { + renderAction("draft"); + + expect( + screen.getByRole("button", { name: "core.content.publish.title" }), + ).toBeDefined(); + }); + + it("offers Unpublish for a published row", () => { + renderAction("published"); + + expect( + screen.getByRole("button", { name: "core.content.unpublish.title" }), + ).toBeDefined(); + }); + + it("renders nothing without can_publish", () => { + // Gated by `can_publish`, never by `can_edit` - a role may write drafts + // without being allowed to put them on the internet. + canPublish = false; + const { container } = renderAction("draft"); + + expect(container.innerHTML).toBe(""); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("labels the button for screen readers", () => { + renderAction("draft"); + + expect( + screen + .getByRole("button", { name: "core.content.publish.title" }) + .getAttribute("aria-label"), + ).toBe("core.content.publish.title"); + }); + + describe("confirmation", () => { + it("asks before publishing, and does not fire until confirmed", async () => { + renderAction("draft"); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.publish.title" }), + ); + + expect( + await screen.findByRole("alertdialog", undefined, { timeout: 3000 }), + ).toBeDefined(); + // Opening the dialog is not the mutation. + expect(publish).not.toHaveBeenCalled(); + }); + + it("publishes when confirmed, and reports it", async () => { + renderAction("draft"); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.publish.title" }), + ); + fireEvent.click( + await screen.findByRole( + "button", + { name: "core.content.publish.confirm" }, + { timeout: 3000 }, + ), + ); + + await waitFor(() => { + expect(publish).toHaveBeenCalledWith("test.post", 7); + }); + expect(unpublish).not.toHaveBeenCalled(); + await waitFor(() => { + expect(success).toHaveBeenCalledWith("core.content.publish.success", { + description: "Hello world", + }); + }); + }); + + it("unpublishes a published row", async () => { + renderAction("published"); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.unpublish.title" }), + ); + fireEvent.click( + await screen.findByRole( + "button", + { name: "core.content.unpublish.confirm" }, + { timeout: 3000 }, + ), + ); + + await waitFor(() => { + expect(unpublish).toHaveBeenCalledWith("test.post", 7); + }); + expect(publish).not.toHaveBeenCalled(); + }); + + it("shows the mapped message on failure", async () => { + publish.mockResolvedValue({ error: "", status: 403 }); + renderAction("draft"); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.publish.title" }), + ); + fireEvent.click( + await screen.findByRole( + "button", + { name: "core.content.publish.confirm" }, + { timeout: 3000 }, + ), + ); + + await waitFor(() => { + expect(error).toHaveBeenCalledWith("core.global.errors.title", { + description: "core.content.errors.forbidden", + }); + }); + expect(success).not.toHaveBeenCalled(); + }); + + it("falls back to the server-error message for an unmapped status", async () => { + publish.mockResolvedValue({ error: "", status: 500 }); + renderAction("draft"); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.publish.title" }), + ); + fireEvent.click( + await screen.findByRole( + "button", + { name: "core.content.publish.confirm" }, + { timeout: 3000 }, + ), + ); + + await waitFor(() => { + expect(error).toHaveBeenCalledWith("core.global.errors.title", { + description: "core.global.errors.internal_server_error", + }); + }); + }); + + it("stays open on failure and closes on success", async () => { + publish.mockResolvedValue({ error: "", status: 500 }); + renderAction("draft"); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.publish.title" }), + ); + fireEvent.click( + await screen.findByRole( + "button", + { name: "core.content.publish.confirm" }, + { timeout: 3000 }, + ), + ); + + await waitFor(() => { + expect(error).toHaveBeenCalled(); + }); + // The reason stays on screen next to the thing that failed. + expect(screen.queryByRole("alertdialog")).not.toBeNull(); + + publish.mockResolvedValue({}); + fireEvent.click( + screen.getByRole("button", { name: "core.content.publish.confirm" }), + ); + + await waitFor(() => { + expect(screen.queryByRole("alertdialog")).toBeNull(); + }); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/publish-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/publish-action.tsx new file mode 100644 index 000000000..68d979daa --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/publish-action.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { EyeOffIcon, SendIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +import { contentErrorKey } from "../lib/mutation-feedback"; +import { + publishContentAction, + unpublishContentAction, +} from "./mutation-api.server"; + +/** + * The publish/unpublish row action. + * + * One button that flips with the row's state, rather than two that are half + * disabled: a draft can only be published and a published row can only be + * unpublished, so showing both would be showing a dead one. + * + * Gated by `can_publish`, never by `can_edit`. That separation is the whole + * point of the permission: a role can be trusted to write drafts without being + * trusted to put them on the internet. + */ +export const PublishContentAction = ({ + contentTypeId, + id, + permissionModule, + pluginId, + singular, + status, + title, +}: { + contentTypeId: string; + id: number; + permissionModule: string; + pluginId: string; + singular: string; + status: unknown; + title: string; +}) => { + const tPublish = useTranslations("core.content.publish"); + const tUnpublish = useTranslations("core.content.unpublish"); + const tErrors = useTranslations("core.global.errors"); + const tContentErrors = useTranslations("core.content.errors"); + const canPublish = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.publish, + plugin: pluginId, + }); + + if (!canPublish) return null; + + const published = status === "published"; + const t = published ? tUnpublish : tPublish; + const label = t("title", { name: singular }); + const Icon = published ? EyeOffIcon : SendIcon; + + return ( + + + ( + {title} + ), + })} + onSubmit={async ({ onClose }) => { + const mutation = published + ? await unpublishContentAction(contentTypeId, id) + : await publishContentAction(contentTypeId, id); + + if (mutation.error !== undefined) { + const errorKey = contentErrorKey(mutation.status); + + toast.error(tErrors("title"), { + description: errorKey + ? tContentErrors(errorKey) + : tErrors("internal_server_error"), + }); + + // Left open on failure, so the reason is still on screen next to + // the thing that failed - the same behaviour as the delete dialog. + return; + } + + toast.success(t("success", { name: singular }), { + description: title, + }); + onClose(); + }} + submitVariant={published ? "destructive" : "default"} + textSubmit={t("confirm")} + title={label} + > + + + + } + /> + + + {label} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx new file mode 100644 index 000000000..4c2bebbd7 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ContentColumnSpec } from "@/content/admin/spec"; + +import { ContentCell, type ContentRowData } from "./cells"; + +vi.mock("next-intl", () => ({ + useFormatter: () => ({ + dateTime: (value: Date) => value.toISOString(), + relativeTime: (value: Date) => value.toISOString(), + }), + useLocale: () => "en", + useNow: () => new Date("2026-08-03T12:00:00.000Z"), + useTranslations: () => (key: string) => key, +})); + +const statusLabels = { draft: "Draft", published: "Published" }; + +const cell = (spec: ContentColumnSpec, row: Partial) => + render( + , + ); + +describe("ContentCell", () => { + describe("the generated publication column", () => { + const spec: ContentColumnSpec = { + kind: "publication", + label: "Status", + name: "status", + }; + + it("renders a draft badge", () => { + cell(spec, { status: "draft" }); + + expect(screen.getByText("Draft")).toBeDefined(); + expect(screen.queryByText("Published")).toBeNull(); + }); + + it("renders a published badge", () => { + cell(spec, { status: "published" }); + + expect(screen.getByText("Published")).toBeDefined(); + }); + + // Before the publication kind existed this column fell through to + // "system", which renders anything that is not `id` as a date. + it("does not render the status as a date", () => { + const { container } = cell(spec, { status: "published" }); + + expect(container.querySelector("time")).toBeNull(); + }); + }); + + it("still renders the system timestamps as dates", () => { + const { container } = cell( + { kind: "system", label: "Updated", name: "updatedAt" }, + { updatedAt: new Date("2026-08-03T10:00:00.000Z") }, + ); + + expect(container.textContent).toContain("2026"); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx index 8756bdfcd..94dcf9940 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -1,4 +1,9 @@ -import { CheckIcon, MinusIcon } from "lucide-react"; +import { + CheckIcon, + CircleCheckIcon, + FileClockIcon, + MinusIcon, +} from "lucide-react"; import type { ContentColumnSpec } from "@/content/admin/spec"; import type { ContentLabels } from "@/content/server/service"; @@ -36,10 +41,13 @@ export const ContentCell = ({ emptyLabel, row, spec, + statusLabels, }: { emptyLabel: string; row: ContentRowData; spec: ContentColumnSpec; + /** Translated `draft`/`published`, for the generated publication column. */ + statusLabels: { draft: string; published: string }; }) => { const value = row[spec.name]; @@ -77,6 +85,21 @@ export const ContentCell = ({ case "number": return {asText(value)}; + case "publication": { + const published = value === "published"; + + return ( + + {published ? ( + + ) : ( + + )} + {published ? statusLabels.published : statusLabels.draft} + + ); + } + case "system": return spec.name === "id" ? ( {asText(value)} diff --git a/packages/vitnode/src/views/admin/views/content/table/content-table-view.test.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.test.tsx new file mode 100644 index 000000000..a4bb15ce4 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.test.tsx @@ -0,0 +1,122 @@ +import type { ReactElement } from "react"; + +import { describe, expect, it, vi } from "vitest"; + +import type { AnyContentTypeDefinition } from "@/content/types"; + +import { + testArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +// Reached through the server actions the row buttons import. +vi.mock("server-only", () => ({})); + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => { + await Promise.resolve(); + + return (key: string) => key; + }, +})); + +// Pulled in transitively by the row actions, and it builds a real next-intl +// router at module scope. +vi.mock("@/lib/navigation", () => ({ + Link: () => null, + getPathname: () => "", + redirect: () => undefined, + usePathname: () => "", + useRouter: () => ({ push: () => undefined, refresh: () => undefined }), +})); + +vi.mock("@/content/admin/fetch.server", () => ({ + contentApiFetch: async () => { + await Promise.resolve(); + + return { status: 200 }; + }, +})); + +const { ContentTableView } = await import("./content-table-view"); + +/** + * The `order` prop the view hands `DataTable`. + * + * Rendering is beside the point here: what the table can sort by is decided + * before a single row exists, and getting it wrong shows up as a header with no + * button rather than as an error. + */ +const orderProp = async (definition: AnyContentTypeDefinition) => { + const element = (await ContentTableView({ + columnSpecs: [], + entry: { + definition, + pluginId: "@vitnode/example", + registration: {}, + } as never, + formSpec: {} as never, + searchParams: {}, + })) as ReactElement<{ + order: { columns: string[]; defaultOrder: { column: string } }; + }>; + + return element.props.order; +}; + +describe("sortable columns", () => { + it("offers every column the generated route accepts", async () => { + // The route's `orderBy` enum is `orderableColumns(definition)`. Passing + // anything narrower here leaves a header unsortable that the backend would + // have answered. + expect((await orderProp(testPostContentType)).columns).toEqual([ + "title", + "id", + "createdAt", + "updatedAt", + "status", + "publishedAt", + ]); + }); + + it.each(["id", "createdAt", "updatedAt"])( + "includes the system column %s", + async name => { + expect((await orderProp(testPostContentType)).columns).toContain(name); + }, + ); + + it.each(["status", "publishedAt"])( + "includes the publication column %s", + async name => { + expect((await orderProp(testPostContentType)).columns).toContain(name); + }, + ); + + it("invents no publication columns without publication", async () => { + const columns = (await orderProp(testArticleContentType)).columns; + + // `test.article` declares its own `status` field and does not opt into + // publication, so `status` is sortable only because it is in the configured + // allowlist - and `publishedAt` does not exist at all. + expect(columns).not.toContain("publishedAt"); + expect(columns).toEqual([ + "title", + "status", + "id", + "createdAt", + "updatedAt", + ]); + }); + + it("keeps the configured fields sortable", async () => { + expect((await orderProp(testPostContentType)).columns).toContain("title"); + }); + + it("leaves the configured default ordering alone", async () => { + expect((await orderProp(testPostContentType)).defaultOrder).toEqual({ + column: "publishedAt", + order: "desc", + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx index 7de1403e6..55fcfcf9c 100644 --- a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx @@ -8,11 +8,13 @@ import type { ContentColumnSpec, ContentFormSpec } from "@/content/admin/spec"; import { zodPaginationPageInfo } from "@/api/lib/with-pagination"; import { DataTable } from "@/components/table/data-table"; import { contentApiFetch } from "@/content/admin/fetch.server"; +import { orderableColumns } from "@/content/registry"; import type { ContentRowData } from "./cells"; import { DeleteContentAction } from "../actions/delete-action"; import { EditContentAction } from "../actions/edit-action"; +import { PublishContentAction } from "../actions/publish-action"; import { ContentCell } from "./cells"; const zodList = z.object({ @@ -62,6 +64,10 @@ export const ContentTableView = async ({ }; const emptyLabel = t("table.empty_value"); + const statusLabels = { + draft: t("status.draft"), + published: t("status.published"), + }; const titleField = definition.admin.titleField; const columns: ColumnDef[] = [ @@ -75,7 +81,12 @@ export const ContentTableView = async ({ cell: ({ row }) => { if (!override) { return ( - + ); } @@ -92,7 +103,8 @@ export const ContentTableView = async ({ id: "actions", header: "", align: "right", - className: "w-20", + // Room for the third button publication adds. + className: definition.publication.enabled ? "w-28" : "w-20", cell: ({ row }) => { const title = titleField && typeof row[titleField] === "string" @@ -101,6 +113,17 @@ export const ContentTableView = async ({ return ( <> + {definition.publication.enabled ? ( + + ) : null} buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, - modules: [adminModule], + modules: [ + adminModule, + buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [articleContent, categoryContent], + }), + ], }); diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 61c82134f..295c58e5e 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -1 +1,16 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/example" as const }; + +/** + * Every committed migration in the docs app that touches `example_*`, in the + * order the migrator applies them. + * + * The two database test suites both replay this list - one asserts the DDL as + * text, the other runs it against a real Postgres - so a new migration only has + * to be added here. It lives outside `src/database/` on purpose: Drizzle Kit + * globs that folder and executes everything it finds. + */ +export const EXAMPLE_MIGRATIONS = [ + "0022_add_example_content.sql", + "0023_add_publication_to_example_articles.sql", + "0024_add_example_article_slug.sql", +]; diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index eb8bd06a2..e6eaaec3c 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -3,7 +3,13 @@ import { defineContentType, field } from "@vitnode/core/content"; import { categoryContentType } from "./category"; /** - * Exercises every field kind the Content Engine supports in MVP 1. + * Exercises every field kind the Content Engine supports, plus the draft -> + * published lifecycle. + * + * `status` and `publishedAt` are *not* declared here: `publication` generates + * them, and declaring either alongside it is a define-time error. They are + * read-only on the wire - `service.publish` / `service.unpublish` and the two + * generated routes are the only things that move them. * * Client-safe by construction - zod and plain objects only - so the same object * is imported by `config.tsx` (the AdminCP), by `config.api.ts` (the routes and @@ -15,16 +21,14 @@ export const articleContentType = defineContentType({ fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), + // Derived from the title when the payload omits it, and never re-derived + // afterwards - renaming an article does not move its URL. + slug: field.slug({ source: "title" }), // `unique: true` is all it takes to get a unique index in the migration. code: field.text({ required: true, maxLength: 100, unique: true }), excerpt: field.textarea({ maxLength: 500, nullable: true }), views: field.number({ integer: true, min: 0, defaultValue: 0 }), featured: field.boolean({ defaultValue: false }), - status: field.enum({ - values: ["draft", "published", "archived"], - defaultValue: "draft", - }), - publishedAt: field.dateTime({ nullable: true }), author: field.user(), category: field.relation({ required: true, @@ -33,15 +37,43 @@ export const articleContentType = defineContentType({ }), }, + publication: { enabled: true }, + + // Opt-in, and separate from `publication` on purpose: publishing controls + // what staff can see in the AdminCP badge, this controls what the internet + // can read. `code`, `views` and `author` are absent, so they never leave + // Postgres - the author especially, since a user field resolves to a person. + publicApi: { + enabled: true, + path: "articles", + fields: ["title", "slug", "excerpt", "featured", "category", "publishedAt"], + searchableFields: ["title", "excerpt"], + orderableFields: ["publishedAt", "title"], + filterableFields: ["category", "featured"], + defaultOrderBy: "publishedAt", + defaultOrder: "desc", + }, + + // The generated columns are addressable here too. `(status, publishedAt)` is + // generated automatically; this one backs "newest drafts first". indexes: [{ on: ["status", "createdAt"] }], admin: { label: { plural: "Example Articles", singular: "Example Article" }, titleField: "title", list: { - columns: ["title", "code", "status", "category", "author", "updatedAt"], + columns: [ + "status", + "title", + "slug", + "code", + "category", + "author", + "publishedAt", + "updatedAt", + ], searchableFields: ["title", "code", "excerpt"], - orderableFields: ["title", "code", "status"], + orderableFields: ["title", "code", "slug"], defaultOrderBy: "updatedAt", defaultOrder: "desc", }, diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index b5bc3a14a..b81e566bb 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url"; import postgres from "postgres"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { EXAMPLE_MIGRATIONS } from "@/const"; + import { articleContent } from "./articles"; import { categoryContent } from "./categories"; @@ -35,14 +37,73 @@ const databaseName = (() => { const here = dirname(fileURLToPath(import.meta.url)); -/** The committed migration - the exact DDL a fresh database would run. */ -const migrationSql = readFileSync( - resolve( - here, - "../../../../apps/docs/migrations/0022_add_example_content.sql", - ), - "utf8", -); +/** The committed migrations - the exact DDL a fresh database would run. */ +const migrationSql = (files: readonly string[]): string => + files + .map(file => + readFileSync( + resolve(here, "../../../../apps/docs/migrations", file), + "utf8", + ), + ) + .join("\n--> statement-breakpoint\n"); + +const SLUG_MIGRATION = "0024_add_example_article_slug.sql"; +const slugMigrationAt = EXAMPLE_MIGRATIONS.indexOf(SLUG_MIGRATION); + +/** + * The longest title the column allows, so `left(title, 160)` fills the slug + * exactly. Two rows sharing it are the overflow case: the base is already 160 + * characters before anything is appended to it. + */ +const LONG_TITLE = "Lorem ipsum dolor sit amet ".repeat(7).slice(0, 200); + +/** + * Rows the slug backfill has to cope with, inserted *between* `0023` and the + * slug migration so the committed SQL runs against real data rather than an + * empty table. + * + * The ids are **explicit**, because two of these scenarios are about a + * generated value colliding with a natural one and that only happens at + * particular ids. Letting the sequence pick them would make the regression + * appear and disappear with the insertion order. + */ +const BACKFILL_ROWS = [ + // A natural slug that a *rescue* value can be built to match. Suffixing only + // the ambiguous rows turns the pair below into "foo-2" and "foo-3" - and + // "foo-2" is already sitting here, on row 1. + { code: "mig-foo-2", id: 1, title: "Foo 2" }, + { code: "mig-foo-a", id: 2, title: "Foo" }, + { code: "mig-foo-b", id: 3, title: "Foo" }, + + // The same trap, one step shorter: a title that normalises to nothing falls + // back to its bare id, and row 6's title genuinely normalises to that number. + { code: "mig-empty", id: 5, title: "日本語のタイトル" }, + { code: "mig-numeric", id: 6, title: "5" }, + + // Two identical titles that already fill the column. + { code: "mig-long-a", id: 7, title: LONG_TITLE }, + { code: "mig-long-b", id: 8, title: LONG_TITLE }, + + // Unambiguous, and suffixed all the same - that is the trade the fix makes. + { code: "mig-unique", id: 9, title: "Only One Of These" }, +]; + +interface BackfilledRow { + code: string; + id: number; + slug: string; +} + +/** What the backfill produced, captured before the fixture rows are removed. */ +let backfilled: BackfilledRow[] = []; + +const backfilledBy = (code: string): BackfilledRow => { + const row = backfilled.find(item => item.code === code); + if (!row) throw new Error(`No migrated row for "${code}".`); + + return row; +}; /** * Stands in for `core_users`, which the `author` field references. @@ -62,6 +123,7 @@ const CORE_USERS_STUB = ` let sql: ReturnType; let context: Context; +let serverMajor = 0; const pgErrorCode = async (run: () => Promise) => { try { @@ -87,17 +149,59 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { sql = postgres(url ?? "", { max: 1, onnotice: () => undefined }); + const [{ version }] = await sql<{ version: number }[]>` + SELECT current_setting('server_version_num')::int AS version + `; + serverMajor = Math.floor(version / 10_000); + await sql.unsafe(` DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; `); await sql.unsafe(CORE_USERS_STUB); - for (const statement of migrationSql.split("--> statement-breakpoint")) { - const trimmed = statement.trim(); - if (trimmed) await sql.unsafe(trimmed); + const run = async (files: readonly string[]) => { + for (const statement of migrationSql(files).split( + "--> statement-breakpoint", + )) { + const trimmed = statement.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + }; + + // Everything up to the slug migration, then rows, then the slug migration. + // A backfill only means anything against a populated table, and this is the + // one statement in the set that can fail on data rather than on schema. + await run(EXAMPLE_MIGRATIONS.slice(0, slugMigrationAt)); + + const [seedCategory] = await sql<{ id: number }[]>` + INSERT INTO "example_categories" ("name") VALUES ('Backfill') RETURNING "id" + `; + for (const row of BACKFILL_ROWS) { + await sql` + INSERT INTO "example_articles" ("id", "category", "code", "title") + VALUES (${row.id}, ${seedCategory.id}, ${row.code}, ${row.title}) + `; } + await run(EXAMPLE_MIGRATIONS.slice(slugMigrationAt)); + + backfilled = await sql` + SELECT "id", "code", "slug" FROM "example_articles" ORDER BY "id" + `; + + // Out of the way, so every other test starts against an empty table. + await sql`DELETE FROM "example_articles"`; + await sql`DELETE FROM "example_categories"`; + // Explicit ids bypass the sequence, so move it past them - otherwise the + // first row the service creates would try to reuse id 1. + await sql` + SELECT setval( + pg_get_serial_sequence('example_articles', 'id'), + ${Math.max(...BACKFILL_ROWS.map(row => row.id))} + ) + `; + context = { get: (key: string) => key === "db" ? drizzle(sql, { casing: "camelCase" }) : undefined, @@ -108,6 +212,100 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { await sql?.end(); }); + describe("the slug backfill", () => { + it("gave every existing row a slug", () => { + expect(backfilled).toHaveLength(BACKFILL_ROWS.length); + expect(backfilled.every(row => typeof row.slug === "string")).toBe(true); + expect(backfilled.every(row => row.slug.length > 0)).toBe(true); + }); + + it("kept every slug inside varchar(160)", () => { + // The column would have rejected anything longer, so this is really a + // statement about the two rows whose base slug already filled it before + // the id was appended. + expect(backfilled.every(row => row.slug.length <= 160)).toBe(true); + }); + + it("made every slug unique", () => { + const slugs = backfilled.map(row => row.slug); + + expect(new Set(slugs).size).toBe(slugs.length); + }); + + it("left no leading or trailing dash", () => { + expect(backfilled.every(row => !/^-|-$/.test(row.slug))).toBe(true); + }); + + it("ends every slug with the row id", () => { + // The whole uniqueness argument: a value is `-`, or `` when + // the title left nothing behind. Two of them can only match if their ids + // do, and ids are the primary key. + expect( + backfilled.every( + row => row.slug === String(row.id) || row.slug.endsWith(`-${row.id}`), + ), + ).toBe(true); + }); + + it("separates two rows with the same ordinary title", () => { + expect(backfilledBy("mig-foo-a").slug).toBe("foo-2"); + expect(backfilledBy("mig-foo-b").slug).toBe("foo-3"); + }); + + it("does not let a generated slug land on a natural one", () => { + // The regression. "Foo 2" normalises to "foo-2" all by itself, and row 2 + // is one of a duplicate pair - so suffixing only the ambiguous rows would + // rescue it *to* "foo-2" and collide with row 1. Suffixing everything + // moves row 1 out of the way instead. + expect(backfilledBy("mig-foo-2").slug).toBe("foo-2-1"); + expect(backfilledBy("mig-foo-a").slug).toBe("foo-2"); + expect(backfilledBy("mig-foo-2").slug).not.toBe( + backfilledBy("mig-foo-a").slug, + ); + }); + + it("does not let an id fallback land on a numeric natural slug", () => { + // Row 5's title normalises to nothing and falls back to "5". Row 6's + // title *is* "5". Leaving unambiguous rows alone would hand both the same + // value; the suffix keeps them apart. + expect(backfilledBy("mig-empty").slug).toBe("5"); + expect(backfilledBy("mig-numeric").slug).toBe("5-6"); + }); + + it("separates two rows whose title fills the whole column", () => { + const first = backfilledBy("mig-long-a"); + const second = backfilledBy("mig-long-b"); + + // Truncated to make room for the suffix rather than truncated after it: + // the id survives, and the whole thing still fits the column. Most of the + // title survives too - this is a trim, not a fallback. + for (const row of [first, second]) { + expect(row.slug.length).toBeLessThanOrEqual(160); + expect(row.slug.length).toBeGreaterThan(150); + expect(row.slug.endsWith(`-${row.id}`)).toBe(true); + } + + expect(first.slug).not.toBe(second.slug); + }); + + it("falls back to the row id for a title that normalises to nothing", () => { + // No transliteration in SQL, so a title in a non-Latin script leaves + // nothing behind. The id is deterministic, non-empty, and the row keeps + // its title. + const row = backfilledBy("mig-empty"); + + expect(row.slug).toBe(String(row.id)); + expect(row.slug.length).toBeGreaterThan(0); + }); + + it("suffixes an unambiguous title too", () => { + // The cost of the fix, stated plainly: a row that needed no help still + // gets an id. Anything cleverer has to reason about what the *other* + // rows resolved to, which is where the collision came from. + expect(backfilledBy("mig-unique").slug).toBe("only-one-of-these-9"); + }); + }); + it("applies the unique index the descriptor asked for", async () => { const indexes = await sql` SELECT indexdef FROM pg_indexes @@ -147,17 +345,17 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { author: user.id, category: category.id, code: "guide-001", - publishedAt: "2026-08-02T10:00:00.000Z", title: "Getting started", }); - // Declared defaults reach the row exactly once, from the create schema. + // Declared defaults reach the row exactly once, from the create schema; + // the publication columns come from their own database defaults. expect(article).toMatchObject({ featured: false, + publishedAt: null, status: "draft", views: 0, }); - expect(article.publishedAt).toBeInstanceOf(Date); await expect(articles.findById(article.id)).resolves.toMatchObject({ code: "guide-001", @@ -170,13 +368,9 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { expect(edges[0].labels).toEqual({ author: "Ada", category: "Guides" }); const updated = await articles.update(article.id, { - status: "published", title: "Getting started, properly", }); - expect([...(updated?.changedFields ?? [])].sort()).toEqual([ - "status", - "title", - ]); + expect([...(updated?.changedFields ?? [])].sort()).toEqual(["title"]); expect(updated?.row.title).toBe("Getting started, properly"); // A unique text field is enforced by Postgres, not just by the descriptor. @@ -201,10 +395,9 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { ), ).resolves.toBe("23503"); - // `onDelete: "restrict"` is what a 409 upstream is actually made of. await expect( pgErrorCode(async () => categories.delete(category.id)), - ).resolves.toBe("23503"); + ).resolves.toBe(serverMajor >= 18 ? "23001" : "23503"); // `onDelete: "set null"` on a nullable user field keeps the article. await sql`DELETE FROM "core_users" WHERE "id" = ${user.id}`; @@ -230,6 +423,327 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { await expect(articles.findById(article.id)).resolves.toBeNull(); }, 60_000); + it("runs the whole publication lifecycle", async () => { + const categories = categoryContent.service(context); + const articles = articleContent.service(context); + + const category = await categories.create({ name: "Lifecycle" }); + const draft = await articles.create({ + category: category.id, + code: "lifecycle-001", + title: "A draft", + }); + + expect(draft.status).toBe("draft"); + expect(draft.publishedAt).toBeNull(); + + const published = await articles.publish(draft.id); + expect(published).toMatchObject({ changed: true }); + expect(published?.row.status).toBe("published"); + expect(published?.publishedAt).toBeInstanceOf(Date); + const firstPublishedAt = published?.publishedAt; + + // Idempotent: a second publish is a successful no-op, and the date it + // stamped the first time is not rewritten. + const again = await articles.publish(draft.id); + expect(again).toMatchObject({ changed: false }); + expect(again?.publishedAt).toEqual(firstPublishedAt); + expect(again?.row.status).toBe("published"); + + // `status` is filterable once publication is enabled. + await expect( + articles.findMany({ filters: { status: "published" } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 1 } }); + await expect( + articles.findMany({ filters: { status: "draft" } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 0 } }); + + // Unpublishing flips the status and deliberately leaves `publishedAt` set, + // so a temporary unpublish does not rewrite the publication date. + const unpublished = await articles.unpublish(draft.id); + expect(unpublished).toMatchObject({ changed: true }); + expect(unpublished?.row.status).toBe("draft"); + expect(unpublished?.publishedAt).toEqual(firstPublishedAt); + + await expect(articles.unpublish(draft.id)).resolves.toMatchObject({ + changed: false, + }); + + // Republishing keeps the original date rather than moving the article to + // the top of a `publishedAt desc` feed. + const republished = await articles.publish(draft.id); + expect(republished).toMatchObject({ changed: true }); + expect(republished?.publishedAt).toEqual(firstPublishedAt); + + // A field update never touches the publication columns. + const edited = await articles.update(draft.id, { title: "Still live" }); + expect(edited?.changedFields).toEqual(["title"]); + expect(edited?.row.status).toBe("published"); + expect(edited?.row.publishedAt).toEqual(firstPublishedAt); + + await expect(articles.publish(999_999)).resolves.toBeNull(); + await expect(articles.unpublish(999_999)).resolves.toBeNull(); + + await articles.delete(draft.id); + await categories.delete(category.id); + }, 60_000); + + it("applies the generated publication index", async () => { + const indexes = await sql` + SELECT indexdef FROM pg_indexes + WHERE tablename = 'example_articles' + AND indexname = 'example_articles_status_published_at_idx' + `; + + expect(indexes).toHaveLength(1); + // Postgres only quotes the identifier that needs it. + expect(indexes[0].indexdef).toContain(`btree (status, "publishedAt")`); + }); + + it("defaults new rows to draft at the database level", async () => { + const [column] = await sql< + { column_default: null | string; is_nullable: string }[] + >` + SELECT column_default, is_nullable FROM information_schema.columns + WHERE table_name = 'example_articles' AND column_name = 'status' + `; + + expect(column.is_nullable).toBe("NO"); + expect(column.column_default).toContain("draft"); + }); + + it("applies the slug column and its unique index", async () => { + const [column] = await sql< + { character_maximum_length: number; is_nullable: string }[] + >` + SELECT character_maximum_length, is_nullable + FROM information_schema.columns + WHERE table_name = 'example_articles' AND column_name = 'slug' + `; + + // The backfill ran before the tightening, or this migration would not have + // applied at all. + expect(column.is_nullable).toBe("NO"); + expect(column.character_maximum_length).toBe(160); + + const indexes = await sql` + SELECT indexdef FROM pg_indexes + WHERE tablename = 'example_articles' + AND indexname = 'example_articles_slug_key' + `; + + expect(indexes).toHaveLength(1); + expect(indexes[0].indexdef).toContain("CREATE UNIQUE INDEX"); + }); + + it("runs the whole slug lifecycle", async () => { + const categories = categoryContent.service(context); + const articles = articleContent.service(context); + + const category = await categories.create({ name: "Slugs" }); + + const article = await articles.create({ + category: category.id, + code: "slug-001", + title: "Zażółć gęślą jaźń", + }); + + // Derived from the title, transliterated, and stored as written. + expect(article.slug).toBe("zazolc-gesla-jazn"); + + // Filterable by equality - the lookup a public detail route will need. + await expect( + articles.findMany({ filters: { slug: "zazolc-gesla-jazn" } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 1 } }); + + // Renaming does not move the URL. This is the whole reason slugs are a + // dedicated kind rather than a text field with a source. + const renamed = await articles.update(article.id, { + title: "A completely different title", + }); + expect(renamed?.changedFields).toEqual(["title"]); + expect(renamed?.row.slug).toBe("zazolc-gesla-jazn"); + + // Sending it explicitly is the only way to change it, and it is normalised + // on the way in whoever wrote it. + const moved = await articles.update(article.id, { + slug: " A Brand New Slug! ", + }); + expect(moved?.row.slug).toBe("a-brand-new-slug"); + + // Re-sending the stored value in another shape is not a change. + await expect( + articles.update(article.id, { slug: "A Brand New Slug" }), + ).resolves.toMatchObject({ changedFields: [] }); + + // Two articles cannot share a slug. Nothing auto-suffixes: Postgres + // refuses, and the generated route turns 23505 into a 409. + await expect( + pgErrorCode(async () => + articles.create({ + category: category.id, + code: "slug-002", + slug: "a-brand-new-slug", + title: "A different article", + }), + ), + ).resolves.toBe("23505"); + + // Same collision, reached by deriving rather than by sending. + await expect( + pgErrorCode(async () => + articles.create({ + category: category.id, + code: "slug-003", + title: "A Brand New Slug", + }), + ), + ).resolves.toBe("23505"); + + // A title with nothing sluggable in it is refused rather than guessed at. + await expect( + articles.create({ + category: category.id, + code: "slug-004", + title: "日本語のタイトル", + }), + ).rejects.toThrow(/Could not derive "slug" from "title"/); + + await articles.delete(article.id); + await categories.delete(category.id); + }, 60_000); + + it("runs the whole public lifecycle", async () => { + const categories = categoryContent.service(context); + const articles = articleContent.service(context); + const publicArticles = articleContent.publicService?.(context); + if (!publicArticles) throw new Error("Expected a public service."); + + const [user] = await sql<{ id: number }[]>` + INSERT INTO "core_users" ("name") VALUES ('Ada') RETURNING "id" + `; + // Named to be unmistakable in a response body: `example.category` has no + // public API of its own, so this string must never leave the AdminCP. + const category = await categories.create({ name: "Internal-Only-Label" }); + + const article = await articles.create({ + author: user.id, + category: category.id, + code: "public-001", + excerpt: "A summary", + title: "Hello public world", + }); + + // A draft is invisible: not in the list, and not findable by its slug. + await expect(publicArticles.findMany()).resolves.toMatchObject({ + pageInfo: { totalCount: 0 }, + }); + await expect( + publicArticles.findBySlug("hello-public-world"), + ).resolves.toBeNull(); + await expect(publicArticles.findById(article.id)).resolves.toBeNull(); + + await articles.publish(article.id); + + const listed = await publicArticles.findMany(); + expect(listed.pageInfo.totalCount).toBe(1); + + const detail = await publicArticles.findBySlug("hello-public-world"); + expect(detail).not.toBeNull(); + + // Exactly the allowlist. `code`, `views` and `author` are absent, and the + // author especially: a user field resolves to a person. + expect(Object.keys(detail ?? {}).sort()).toEqual([ + "category", + "excerpt", + "featured", + "publishedAt", + "slug", + "title", + ]); + expect(detail).toMatchObject({ + category: { id: category.id }, + excerpt: "A summary", + title: "Hello public world", + }); + // Fetched for the cursor, dropped from the projection. + expect(detail).not.toHaveProperty("id"); + + // The relation is an identifier and nothing else. `example.category` has no + // public API, so reading its `admin.titleField` here would publish another + // content type's private data through this one's allowlist. + expect(detail?.category).toEqual({ id: category.id }); + expect(JSON.stringify(detail)).not.toContain("Internal-Only-Label"); + + // Filtering, search and ordering all work through the public allowlists. + await expect( + publicArticles.findMany({ filters: { category: category.id } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 1 } }); + await expect( + publicArticles.findMany({ query: { search: "summary" } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 1 } }); + await expect( + publicArticles.findMany({ query: { search: "nothing here" } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 0 } }); + await expect( + publicArticles.findMany({ orderBy: { column: "title", order: "asc" } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 1 } }); + + // Changing the slug moves the public URL, and the old one stops resolving. + await articles.update(article.id, { slug: "moved-somewhere-else" }); + await expect( + publicArticles.findBySlug("hello-public-world"), + ).resolves.toBeNull(); + await expect( + publicArticles.findBySlug("moved-somewhere-else"), + ).resolves.not.toBeNull(); + + // A cleared publication date is exactly the leak `IS NOT NULL` prevents. + await sql`UPDATE "example_articles" SET "publishedAt" = NULL WHERE "id" = ${article.id}`; + await expect( + publicArticles.findBySlug("moved-somewhere-else"), + ).resolves.toBeNull(); + + // So is a date in the future, which is what makes scheduling additive. + await sql` + UPDATE "example_articles" + SET "publishedAt" = now() + interval '1 day' + WHERE "id" = ${article.id} + `; + await expect( + publicArticles.findBySlug("moved-somewhere-else"), + ).resolves.toBeNull(); + await expect(publicArticles.findMany()).resolves.toMatchObject({ + pageInfo: { totalCount: 0 }, + }); + + await sql`UPDATE "example_articles" SET "publishedAt" = now() WHERE "id" = ${article.id}`; + await articles.unpublish(article.id); + + // Unpublished: gone from both endpoints even though `publishedAt` is set. + await expect(publicArticles.findMany()).resolves.toMatchObject({ + pageInfo: { totalCount: 0 }, + }); + await expect( + publicArticles.findBySlug("moved-somewhere-else"), + ).resolves.toBeNull(); + + await articles.publish(article.id); + await articles.delete(article.id); + + await expect( + publicArticles.findBySlug("moved-somewhere-else"), + ).resolves.toBeNull(); + + await sql`DELETE FROM "core_users" WHERE "id" = ${user.id}`; + await categories.delete(category.id); + }, 60_000); + + it("has no public service without a public API", () => { + // `example.category` opts into neither publication nor `publicApi`. + expect(categoryContent.publicService).toBeUndefined(); + }); + it("rejects invalid input before it reaches Postgres", async () => { await expect( articleContent diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index 02635306b..d0b2aab0f 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -5,6 +5,8 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { EXAMPLE_MIGRATIONS } from "@/const"; + import { example_articles } from "./articles"; import { example_categories } from "./categories"; @@ -24,17 +26,20 @@ const uniqueIndexNames = (config: typeof articles) => .map(item => item.config.name); /** - * The committed migration for the docs app, which is the one CI applies. It is - * read as text on purpose: this is the artefact a fresh database actually runs, - * so asserting on the Drizzle objects alone would not prove the DDL landed. + * The committed migrations, concatenated in apply order. Read as text on + * purpose: this is the artefact a fresh database actually runs, so asserting on + * the Drizzle objects alone would not prove the DDL landed. */ -const migration = readFileSync( - resolve( - dirname(fileURLToPath(import.meta.url)), - "../../../../apps/docs/migrations/0022_add_example_content.sql", +const migration = EXAMPLE_MIGRATIONS.map(file => + readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../apps/docs/migrations", + file, + ), + "utf8", ), - "utf8", -); +).join("\n"); describe("example_articles", () => { it("is a real table with the expected name", () => { @@ -45,7 +50,7 @@ describe("example_articles", () => { expect(articles.enableRLS).toBe(true); }); - it("exercises every MVP 1 field kind", () => { + it("exercises every field kind", () => { const types = Object.fromEntries( articles.columns.map(column => [column.name, column.getSQLType()]), ); @@ -56,24 +61,55 @@ describe("example_articles", () => { code: "varchar(100)", // text, unique excerpt: "text", // textarea featured: "boolean", - publishedAt: "timestamp", // dateTime - status: "varchar(64)", // enum + slug: "varchar(160)", // slug title: "varchar(200)", // text views: "integer", // number }); }); - it("gives the unique text field a unique index, and nothing else one", () => { - expect(uniqueIndexNames(articles)).toEqual(["example_articles_code_key"]); + it("makes the slug column NOT NULL with no default", () => { + const slug = articles.columns.find(column => column.name === "slug"); + + // A row nobody can address by URL is not worth allowing, and there is no + // sensible default URL to fall back on. + expect(slug?.notNull).toBe(true); + expect(slug?.default).toBeUndefined(); + }); + + it("generates the publication columns instead of declaring them", () => { + const columns = Object.fromEntries( + articles.columns.map(column => [column.name, column]), + ); + + expect(columns.status.getSQLType()).toBe("varchar(32)"); + expect(columns.status.notNull).toBe(true); + expect(columns.status.default).toBe("draft"); + + // "First published at", not "is published" - `unpublish` leaves it set. + expect(columns.publishedAt.getSQLType()).toBe("timestamp"); + expect(columns.publishedAt.notNull).toBe(false); + expect(columns.publishedAt.default).toBeUndefined(); }); - it("indexes the foreign keys, the timestamps and the declared composite", () => { + it("gives the unique text field and the slug a unique index", () => { + // The slug needs no `unique: true` - a URL segment is unique by definition. + expect([...uniqueIndexNames(articles)].sort(byName)).toEqual([ + "example_articles_code_key", + "example_articles_slug_key", + ]); + }); + + it("indexes the foreign keys, the timestamps, the declared composite and publication", () => { expect([...indexNames(articles)].sort(byName)).toEqual([ "example_articles_author_idx", "example_articles_category_idx", "example_articles_code_key", "example_articles_created_at_idx", + "example_articles_slug_key", "example_articles_status_created_at_idx", + // Generated by `publication`: serves the published predicate and the + // default "newest published first" ordering in one. + "example_articles_status_published_at_idx", "example_articles_updated_at_idx", ]); }); @@ -144,6 +180,62 @@ describe("the generated migration", () => { expect(migration).toContain('"example_articles_status_created_at_idx"'); }); + describe("the slug column", () => { + it("arrives nullable, so an existing table can be backfilled", () => { + // `ADD COLUMN ... NOT NULL` with no default fails outright on a populated + // table. The generated one-liner has to be split by hand. + expect(migration).toContain( + 'ALTER TABLE "example_articles" ADD COLUMN "slug" varchar(160);', + ); + expect(migration).not.toContain( + 'ADD COLUMN "slug" varchar(160) NOT NULL', + ); + }); + + it("backfills from the title before tightening the column", () => { + const backfill = migration.indexOf('SET "slug" = NULLIF'); + const notNull = migration.indexOf('ALTER COLUMN "slug" SET NOT NULL'); + const unique = migration.indexOf('"example_articles_slug_key"'); + + expect(backfill).toBeGreaterThan(-1); + expect(notNull).toBeGreaterThan(backfill); + expect(unique).toBeGreaterThan(backfill); + }); + + it("disambiguates with the row id rather than a placeholder", () => { + // No "untitled-1" and nothing random, so the same table migrates to the + // same slugs on every machine that runs it. + expect(migration).toContain(`a."id"`); + expect(migration).not.toMatch(/random\(|gen_random_uuid\(/); + }); + + it("suffixes every row rather than only the ambiguous ones", () => { + // A conditional rescue can produce a value that collides with a natural + // slug it left alone, and that only fails at `CREATE UNIQUE INDEX`. The + // behaviour is asserted for real in `postgres.test.ts`; this catches the + // predicate coming back. + expect(migration).not.toMatch(/WHERE\s+a\."slug"\s+IS\s+NULL/); + }); + + it("truncates the base before appending the id", () => { + // A title can already fill `varchar(160)`, so appending `-` to the + // whole thing would overflow the column. + expect(migration).toContain( + `left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text))`, + ); + }); + }); + + it("narrows the status column and normalises the values it dropped", () => { + expect(migration).toContain( + `ALTER TABLE "example_articles" ALTER COLUMN "status" SET DATA TYPE varchar(32)`, + ); + // `archived` is not a generated status; those rows have to go somewhere. + expect(migration).toContain( + `UPDATE "example_articles" SET "status" = 'draft' WHERE "status" NOT IN ('draft', 'published')`, + ); + }); + it("wires the foreign keys with the declared onDelete behaviour", () => { expect(migration).toContain( 'REFERENCES "public"."core_users"("id") ON DELETE set null', diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json index 930e8ff44..6e042a58d 100644 --- a/plugins/example/src/locales/en.json +++ b/plugins/example/src/locales/en.json @@ -11,18 +11,9 @@ "excerpt": "Excerpt", "views": "Views", "featured": "Featured", - "status": "Status", - "publishedAt": "Published at", "author": "Author", "category": "Category", "updatedAt": "Updated" - }, - "enums": { - "status": { - "draft": "Draft", - "published": "Published", - "archived": "Archived" - } } }, "category": { @@ -40,6 +31,7 @@ "@vitnode/example:example_articles:can_create": "Create articles", "@vitnode/example:example_articles:can_edit": "Edit articles", "@vitnode/example:example_articles:can_delete": "Delete articles", + "@vitnode/example:example_articles:can_publish": "Publish and unpublish articles", "@vitnode/example:example_categories": "Categories", "@vitnode/example:example_categories:can_view": "View categories", "@vitnode/example:example_categories:can_create": "Create categories",