From e7c0e3e84bf83e9293602804798568ce0aa41a09 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 14:31:08 +0200 Subject: [PATCH 01/13] feat: Add content engine stage 2 --- .../docs/dev/content-engine/admincp.mdx | 18 + .../database-and-migrations.mdx | 35 +- .../docs/dev/content-engine/events.mdx | 20 +- .../content/docs/dev/content-engine/index.mdx | 1 + .../docs/dev/content-engine/limitations.mdx | 3 +- .../content/docs/dev/content-engine/meta.json | 1 + .../docs/dev/content-engine/permissions.mdx | 18 +- .../docs/dev/content-engine/publication.mdx | 180 ++ .../docs/dev/content-engine/schemas.mdx | 2 +- .../docs/dev/content-engine/service.mdx | 18 + .../docs/dev/events/built-in-events.mdx | 20 +- ...23_add_publication_to_example_articles.sql | 10 + apps/docs/migrations/meta/0023_snapshot.json | 2514 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + packages/vitnode/src/content/admin/spec.ts | 11 +- packages/vitnode/src/content/const.ts | 20 +- packages/vitnode/src/content/define.ts | 68 +- packages/vitnode/src/content/events.ts | 52 +- packages/vitnode/src/content/index.ts | 9 + packages/vitnode/src/content/indexes.ts | 11 +- .../vitnode/src/content/publication.test-d.ts | 163 ++ .../vitnode/src/content/publication.test.ts | 286 ++ packages/vitnode/src/content/registry.ts | 32 +- packages/vitnode/src/content/schemas.ts | 32 +- .../src/content/server/column-builders.ts | 26 + packages/vitnode/src/content/server/emit.ts | 4 + .../src/content/server/http-errors.test.ts | 10 + .../vitnode/src/content/server/http-errors.ts | 5 + packages/vitnode/src/content/server/index.ts | 11 +- .../vitnode/src/content/server/publication.ts | 52 + packages/vitnode/src/content/server/query.ts | 10 + .../vitnode/src/content/server/routes.test.ts | 164 ++ packages/vitnode/src/content/server/routes.ts | 67 +- .../src/content/server/service.test.ts | 112 + .../vitnode/src/content/server/service.ts | 145 +- packages/vitnode/src/content/server/table.ts | 13 +- packages/vitnode/src/content/server/types.ts | 47 +- packages/vitnode/src/content/types.ts | 127 +- packages/vitnode/src/locales/en.json | 27 +- .../vitnode/src/tests/content-fixtures.ts | 31 + .../admin/views/content/table/cells.test.tsx | 68 + .../views/admin/views/content/table/cells.tsx | 25 +- .../content/table/content-table-view.tsx | 11 +- plugins/example/src/const.ts | 14 + plugins/example/src/content/article.ts | 29 +- plugins/example/src/database/postgres.test.ts | 124 +- plugins/example/src/database/tables.test.ts | 57 +- plugins/example/src/locales/en.json | 10 +- 48 files changed, 4582 insertions(+), 138 deletions(-) create mode 100644 apps/docs/content/docs/dev/content-engine/publication.mdx create mode 100644 apps/docs/migrations/0023_add_publication_to_example_articles.sql create mode 100644 apps/docs/migrations/meta/0023_snapshot.json create mode 100644 packages/vitnode/src/content/publication.test-d.ts create mode 100644 packages/vitnode/src/content/publication.test.ts create mode 100644 packages/vitnode/src/content/server/publication.ts create mode 100644 packages/vitnode/src/views/admin/views/content/table/cells.test.tsx diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index 01fded491..bdae96811 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -76,6 +76,24 @@ 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. + + + Publishing currently happens through the API - `POST /{id}/publish` - or + through `service.publish` from your own code. The row action, its confirmation + dialog and the `can_publish` gating land with the AdminCP publication UX. + + ## 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/database-and-migrations.mdx b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx index 7ec44b46d..54f3be79d 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,38 @@ 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 })`, 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: [ diff --git a/apps/docs/content/docs/dev/content-engine/events.mdx b/apps/docs/content/docs/dev/content-engine/events.mdx index e3b96243b..a07108b01 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 @@ -86,7 +96,13 @@ unlike content types. 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. +field that did not move. Publishing something already published behaves the +same way: a 200, and no event. + +Exactly one event fires per 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". 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/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx index 4ad99ef7b..a7354fe6b 100644 --- a/apps/docs/content/docs/dev/content-engine/index.mdx +++ b/apps/docs/content/docs/dev/content-engine/index.mdx @@ -41,6 +41,7 @@ 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 ## 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..afc0cef3e 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -14,7 +14,8 @@ 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 | diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index 818cb2c6f..ae9ead94f 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -9,6 +9,7 @@ "database-and-migrations", "schemas", "service", + "publication", "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..5f1ea6871 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" } ``` 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..b86d9245b --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/publication.mdx @@ -0,0 +1,180 @@ +--- +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 changes +nothing, writes nothing, and emits nothing - it just tells you `changed: false`. +Double-clicking the button is harmless, and so is a retry. + +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 } +``` + +Exactly one event fires per mutation. Publishing does **not** also emit +`updated`: `changedFields` on that event lists declared fields, and these two +columns are not declared ones, so an `updated` event would have nothing honest +to put in it. A listener that cares about any change subscribes to all of them. + +A no-op publish emits nothing at all. 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. + +## 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*. diff --git a/apps/docs/content/docs/dev/content-engine/schemas.mdx b/apps/docs/content/docs/dev/content-engine/schemas.mdx index 6b197dd17..d5efb2847 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 eight Zod schemas every content type exposes, and the rules they enforce. icon: ShieldCheck --- diff --git a/apps/docs/content/docs/dev/content-engine/service.mdx b/apps/docs/content/docs/dev/content-engine/service.mdx index 239bd5dbb..6e014e46e 100644 --- a/apps/docs/content/docs/dev/content-engine/service.mdx +++ b/apps/docs/content/docs/dev/content-engine/service.mdx @@ -221,6 +221,24 @@ 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, no event +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. + ## options Backs the relation and user pickers, capped and search-filtered. It only accepts 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..567544053 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,21 @@ 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. +returned: a failed validation, a delete blocked by a foreign key, a no-op update +and a no-op publish all emit nothing. + +Exactly one event fires per mutation - `published` and `unpublished` never come +with an `updated` alongside them. **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/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/_journal.json b/apps/docs/migrations/meta/_journal.json index b6f14046d..25ba85303 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1785706178516, "tag": "0022_add_example_content", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1785758740560, + "tag": "0023_add_publication_to_example_articles", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index b86fa1a89..05f55be93 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", }; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 05f196bf0..d5d952aeb 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -4,6 +4,20 @@ */ 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; + +/** `varchar` length of the generated `status` column. */ +export const CONTENT_PUBLICATION_STATUS_LENGTH = 32; + /** * Field kinds a generated equality filter understands. * @@ -69,10 +83,14 @@ export const CONTENT_ENUM_DEFAULT_LENGTH = 64; export const CONTENT_DEFAULT_PAGE_SIZE = 25; export const CONTENT_OPTIONS_LIMIT = 25; -/** Every content type gets these four staff permissions. */ +/** + * 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.ts b/packages/vitnode/src/content/define.ts index ed35a9cbf..ebbeff815 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -4,6 +4,7 @@ import type { ContentFieldMap, ContentFieldsConstraint, ContentIndexInput, + ContentPublicationConfig, ContentTypeDefinition, ResolvedContentAdminConfig, } from "./types"; @@ -13,6 +14,7 @@ import { CONTENT_FIELD_NAME_PATTERN, CONTENT_ID_PATTERN, CONTENT_IDENTIFIER_MAX_LENGTH, + CONTENT_PUBLICATION_FIELDS, CONTENT_SYSTEM_FIELDS, CONTENT_TABLE_NAME_PATTERN, } from "./const"; @@ -26,6 +28,7 @@ const SEARCHABLE_KINDS = new Set([ ]); const systemFields: readonly string[] = CONTENT_SYSTEM_FIELDS; +const publicationFields: readonly string[] = CONTENT_PUBLICATION_FIELDS; const slugifyModule = (value: string): string => value @@ -44,7 +47,11 @@ const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => { 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 +59,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.`, @@ -193,9 +207,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) ?? @@ -225,9 +243,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 +260,7 @@ const resolveAdmin = ( const defaultOrderBy = String(admin.list?.defaultOrderBy ?? "updatedAt"); if ( - !systemFields.includes(defaultOrderBy) && + !generatedColumns.includes(defaultOrderBy) && !orderableFields.includes(defaultOrderBy) ) { throw new ContentEngineError( @@ -281,20 +304,24 @@ const resolveAdmin = ( */ export const defineContentType = < TId extends string, - TFields extends ContentFieldsConstraint, + TFields extends ContentFieldsConstraint, + TPublication extends boolean = false, >({ admin, fields, id, indexes = [], + publication, tableName, }: { - admin: ContentAdminConfig; + admin: ContentAdminConfig; fields: TFields; id: TId; - indexes?: ContentIndexInput[]; + indexes?: ContentIndexInput[]; + /** Opts into the draft/published lifecycle. Omit to stay on Stage 1 behaviour. */ + publication?: ContentPublicationConfig | { enabled: TPublication }; tableName: string; -}): ContentTypeDefinition => { +}): ContentTypeDefinition => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( `Content type id "${id}" must look like "plugin.entity" (lowercase, dot separated).`, @@ -327,13 +354,19 @@ 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]); + const knownColumns = new Set([ + ...fieldNames, + ...systemFields, + ...(publicationEnabled ? publicationFields : []), + ]); const resolvedIndexes = resolveContentIndexes({ contentTypeId: id, declared: indexes.map(index => { @@ -343,10 +376,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); @@ -363,9 +397,15 @@ export const defineContentType = < id, indexes: resolvedIndexes, permissionModule, - schemas: buildContentSchemas>({ + publication: { + enabled: publicationEnabled as TPublication, + }, + schemas: buildContentSchemas< + ContentTypeDefinition + >({ admin: resolvedAdmin, fields: fieldMap, + publication: publicationEnabled, }), tableName, }; 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/index.ts b/packages/vitnode/src/content/index.ts index 7d5234485..84d3233fb 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -31,6 +31,9 @@ export { CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS, + CONTENT_PUBLICATION_FIELDS, + CONTENT_PUBLICATION_STATUS_LENGTH, + CONTENT_PUBLICATION_STATUSES, CONTENT_SYSTEM_FIELDS, CONTENT_TEXT_DEFAULT_LENGTH, RESERVED_FILTER_KEYS, @@ -43,6 +46,8 @@ export type { ContentDeletedPayload, ContentEventAction, ContentEventsFor, + ContentPublishedPayload, + ContentUnpublishedPayload, ContentUpdatedPayload, } from "./events"; export { field } from "./fields"; @@ -81,6 +86,9 @@ export type { ContentNumberField, ContentOnDelete, ContentOrderableFieldName, + ContentPublicationConfig, + ContentPublicationField, + ContentPublicationStatus, ContentReferenceField, ContentReferenceFieldName, ContentRelationField, @@ -95,4 +103,5 @@ export type { FilterableContentFieldName, ResolvedContentAdminConfig, ResolvedContentIndex, + ResolvedContentPublicationConfig, } from "./types"; diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index 297edbb19..0041a6db5 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -121,12 +121,14 @@ 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 })`, * 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 +144,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(); @@ -191,6 +195,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/publication.test-d.ts b/packages/vitnode/src/content/publication.test-d.ts new file mode 100644 index 000000000..6a64f0801 --- /dev/null +++ b/packages/vitnode/src/content/publication.test-d.ts @@ -0,0 +1,163 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentPublicationStatus, + ContentSelect, + ContentUpdateInput, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +type Post = typeof testPostContentType; +type Article = typeof testArticleContentType; + +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(); + 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"] }, + }, + }); + }); + }); +}); 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.ts b/packages/vitnode/src/content/registry.ts index a9f2300d0..9b1cabd7b 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"; @@ -142,11 +143,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 +167,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 +192,21 @@ 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 : []), ]; diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 62aa40aa1..e53870bd9 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -10,7 +10,12 @@ import type { ResolvedContentAdminConfig, } from "./types"; -import { CONTENT_SYSTEM_FIELDS, isFilterableFieldKind } from "./const"; +import { + CONTENT_PUBLICATION_FIELDS, + CONTENT_PUBLICATION_STATUSES, + CONTENT_SYSTEM_FIELDS, + isFilterableFieldKind, +} from "./const"; export interface ContentSchemas { /** Request body for create. Rejects unknown keys and system columns. */ @@ -207,12 +212,23 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => export const buildContentSchemas = ({ admin, fields, + publication = false, }: { admin: ResolvedContentAdminConfig; fields: ContentFieldMap; + 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 +237,7 @@ export const buildContentSchemas = ({ applyNullable(baseSelectSchema(fields[name]), fields[name]), ]), ), + ...publicationSelectShape, createdAt: z.date(), updatedAt: z.date(), }; @@ -235,7 +252,11 @@ 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); return { @@ -244,7 +265,12 @@ export const buildContentSchemas = ({ // 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(), diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index d967f1ae2..adc25774c 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -14,6 +14,8 @@ import type { ContentFieldDescriptor } from "../types"; import { CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_PUBLICATION_STATUS_LENGTH, + CONTENT_PUBLICATION_STATUSES, CONTENT_TEXT_DEFAULT_LENGTH, } from "../const"; import { ContentEngineError } from "../errors"; @@ -34,6 +36,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. * 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..e97856e7e 100644 --- a/packages/vitnode/src/content/server/http-errors.test.ts +++ b/packages/vitnode/src/content/server/http-errors.test.ts @@ -41,6 +41,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); diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index f1a393797..6a2bb9960 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -5,6 +5,7 @@ import { ZodError } from "zod"; 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. @@ -50,6 +51,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..210004da3 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -6,13 +6,18 @@ * 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 { publicationMethods, publishedCondition } from "./publication"; export { buildFilterCondition, buildOrderColumn, @@ -29,7 +34,10 @@ export type { ContentLabels, ContentListRow, ContentPageInfo, + ContentPublicationMethods, + ContentPublicationResult, ContentService, + ContentServiceBase, ContentServiceOptions, ContentUpdateResult, } from "./service"; @@ -38,6 +46,7 @@ export type { ContentColumnBuilder, ContentColumnBuilders, ContentColumnName, + ContentPublicationColumnBuilders, ContentReferences, ContentSystemColumnBuilders, ContentTable, diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts new file mode 100644 index 000000000..708011c7d --- /dev/null +++ b/packages/vitnode/src/content/server/publication.ts @@ -0,0 +1,52 @@ +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 one definition of "published", applied centrally so no caller has to + * remember it. + * + * `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. `IS NOT NULL` is what keeps a row + * whose timestamp was cleared by hand out of public results. + */ +export const publishedCondition = ( + columns: Record, +): 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.ts b/packages/vitnode/src/content/server/query.ts index 0f30bd419..9b77b4283 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -58,17 +58,27 @@ export const buildFilterCondition = ({ contentTypeId, fields, filters, + publication = false, }: { 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; + // `status` is a generated column, not a declared field, so it has no + // descriptor to check - the schema's `z.enum` already narrowed the value. + if (publication && name === "status" && columns.status) { + 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/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.ts b/packages/vitnode/src/content/server/service.test.ts index 5c74280b2..27adae4da 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 { @@ -24,6 +25,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 +441,112 @@ 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"]), + ); + }); + }); }); diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 3b368ec84..52ec99762 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -7,7 +7,7 @@ import type { } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import { and, eq } from "drizzle-orm"; +import { and, eq, ne, sql } from "drizzle-orm"; import { alias, getTableConfig } from "drizzle-orm/pg-core"; import type { ContentSchemas } from "../schemas"; @@ -23,7 +23,11 @@ import type { } from "../types"; import { withPagination } from "../../api/lib/with-pagination"; -import { CONTENT_DEFAULT_PAGE_SIZE, CONTENT_OPTIONS_LIMIT } from "../const"; +import { + CONTENT_DEFAULT_PAGE_SIZE, + CONTENT_OPTIONS_LIMIT, + CONTENT_PUBLICATION_FIELDS, +} from "../const"; import { ContentEngineError } from "../errors"; import { orderableColumns } from "../registry"; import { @@ -75,7 +79,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, @@ -221,7 +268,14 @@ 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], @@ -266,7 +320,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 @@ -306,6 +429,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); @@ -423,4 +547,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/types.ts b/packages/vitnode/src/content/types.ts index a6b0651aa..8ba33a3ed 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,11 +1,19 @@ import type { CONTENT_FILTERABLE_FIELD_KINDS, + 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. */ @@ -150,11 +158,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 +245,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 +318,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,6 +358,40 @@ 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; + // --------------------------------------------------------------------------- // Definition // --------------------------------------------------------------------------- @@ -324,6 +399,7 @@ export interface ResolvedContentIndex { export interface ContentTypeDefinition< TId extends string = string, TFields = ContentFieldMap, + TPublication extends boolean = boolean, > { admin: ResolvedContentAdminConfig; fields: TFields; @@ -332,8 +408,9 @@ export interface ContentTypeDefinition< indexes: ResolvedContentIndex[]; /** Derived from `admin.permissionModule` or `admin.label.plural`. */ permissionModule: string; + publication: ResolvedContentPublicationConfig; /** Zod schemas generated from `fields`. */ - schemas: ContentSchemas>; + schemas: ContentSchemas>; tableName: string; } @@ -347,7 +424,7 @@ export type ContentFieldsOf = TDefinition extends { : never; export type ContentSelect = Prettify< - { + ContentPublicationColumns & { [K in keyof ContentFieldsOf]: ContentFieldValue< ContentFieldsOf[K] >; @@ -401,12 +478,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. 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..a72e9789b 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,30 @@ export const testArticleContentType = defineContentType({ }, }, }); + +/** The Stage 2 shape: `status` and `publishedAt` come from `publication`. */ +export const testPostContentType = defineContentType({ + id: "test.post", + tableName: "test_posts", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + 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 }, + 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/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.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx index 7de1403e6..350df543b 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 @@ -62,6 +62,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 +79,12 @@ export const ContentTableView = async ({ cell: ({ row }) => { if (!override) { return ( - + ); } diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 61c82134f..e78557d5c 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -1 +1,15 @@ 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", +]; diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index eb8bd06a2..6fddef169 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 @@ -20,11 +26,6 @@ export const articleContentType = defineContentType({ 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 +34,27 @@ export const articleContentType = defineContentType({ }), }, + publication: { enabled: true }, + + // 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", + "code", + "category", + "author", + "publishedAt", + "updatedAt", + ], searchableFields: ["title", "code", "excerpt"], - orderableFields: ["title", "code", "status"], + orderableFields: ["title", "code"], defaultOrderBy: "updatedAt", defaultOrder: "desc", }, diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index b5bc3a14a..79655809d 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,10 @@ 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 = EXAMPLE_MIGRATIONS.map(file => + readFileSync(resolve(here, "../../../../apps/docs/migrations", file), "utf8"), +).join("\n--> statement-breakpoint\n"); /** * Stands in for `core_users`, which the `author` field references. @@ -62,6 +60,7 @@ const CORE_USERS_STUB = ` let sql: ReturnType; let context: Context; +let serverMajor = 0; const pgErrorCode = async (run: () => Promise) => { try { @@ -87,6 +86,11 @@ 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; @@ -147,17 +151,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 +174,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 +201,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 +229,95 @@ 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("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..11bd7fdb4 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,40 @@ describe("example_articles", () => { code: "varchar(100)", // text, unique excerpt: "text", // textarea featured: "boolean", - publishedAt: "timestamp", // dateTime - status: "varchar(64)", // enum title: "varchar(200)", // text views: "integer", // number }); }); + 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("gives the unique text field a unique index, and nothing else one", () => { expect(uniqueIndexNames(articles)).toEqual(["example_articles_code_key"]); }); - it("indexes the foreign keys, the timestamps and the declared composite", () => { + 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_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 +165,16 @@ describe("the generated migration", () => { expect(migration).toContain('"example_articles_status_created_at_idx"'); }); + 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", From 236520ad6e37ea4d7262393da5f6505570684892 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 15:07:00 +0200 Subject: [PATCH 02/13] feat: Publication foundation step 1 --- .../docs/dev/content-engine/events.mdx | 60 +++++++++-- .../docs/dev/content-engine/limitations.mdx | 24 ++++- .../docs/dev/content-engine/publication.mdx | 100 ++++++++++++++++-- .../docs/dev/content-engine/service.mdx | 7 +- .../docs/dev/events/built-in-events.mdx | 16 +-- packages/vitnode/src/content/const.ts | 21 ++++ packages/vitnode/src/content/index.ts | 1 + .../vitnode/src/content/publication.test-d.ts | 53 ++++++++++ .../src/content/server/publication.test.ts | 69 ++++++++++++ .../vitnode/src/content/server/publication.ts | 31 +++++- .../vitnode/src/content/server/query.test.ts | 95 +++++++++++++++++ packages/vitnode/src/content/server/query.ts | 16 ++- .../src/content/server/service.test-d.ts | 71 +++++++++++++ packages/vitnode/src/content/types.ts | 11 +- 14 files changed, 539 insertions(+), 36 deletions(-) create mode 100644 packages/vitnode/src/content/server/publication.test.ts diff --git a/apps/docs/content/docs/dev/content-engine/events.mdx b/apps/docs/content/docs/dev/content-engine/events.mdx index a07108b01..64411d561 100644 --- a/apps/docs/content/docs/dev/content-engine/events.mdx +++ b/apps/docs/content/docs/dev/content-engine/events.mdx @@ -88,21 +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. Publishing something already published behaves the -same way: a 200, and no event. +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. -Exactly one event fires per 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". +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/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index afc0cef3e..692c2a516 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -19,7 +19,8 @@ other 20%, so you find out here rather than halfway through building. | 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 frontend routes | Not generated yet. Write the route and gate it with `publishedCondition` | +| Public field projection | Select only the columns you mean to expose, by hand | | 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 | @@ -95,8 +96,25 @@ Postgres, when the migration runs. ## Public API is AdminCP-only 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. +That includes the publish and unpublish routes: 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.** + +There is no generated public read endpoint yet - no list route, no detail route, +no field allowlist, no cache invalidation. Serving published content is your own +route today, and the server surface exports the predicate so the one part that +is easy to get wrong is not written by hand: + +```ts +import { publishedCondition } from "@vitnode/core/content/server"; + +.where(publishedCondition(articleContent.columns)) +``` + +Selecting only the columns you intend to expose is still yours to get right. +[Serving published content](/docs/dev/content-engine/publication#serving-published-content) +has the full example. ## Content types are code diff --git a/apps/docs/content/docs/dev/content-engine/publication.mdx b/apps/docs/content/docs/dev/content-engine/publication.mdx index b86d9245b..0fd55c1b7 100644 --- a/apps/docs/content/docs/dev/content-engine/publication.mdx +++ b/apps/docs/content/docs/dev/content-engine/publication.mdx @@ -74,9 +74,10 @@ 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 changes -nothing, writes nothing, and emits nothing - it just tells you `changed: false`. -Double-clicking the button is harmless, and so is a retry. +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: @@ -126,12 +127,43 @@ content.example.article.published { contentId, publishedAt } content.example.article.unpublished { contentId } ``` -Exactly one event fires per mutation. Publishing does **not** also emit -`updated`: `changedFields` on that event lists declared fields, and these two -columns are not declared ones, so an `updated` event would have nothing honest -to put in it. A listener that cares about any change subscribes to all of them. +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. -A no-op publish emits nothing at all. See [Generated events](/docs/dev/content-engine/events). +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 @@ -172,9 +204,61 @@ 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. + + +Until the generated public read layer lands, serving published content is your +own route - and the one thing worth not writing by hand is the predicate. The +server surface exports it: + +```ts title="src/api/modules/articles/routes/get.route.ts" +import { publishedCondition } from "@vitnode/core/content/server"; + +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(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 a supported escape hatch, not a placeholder: when the generated public +Content Service arrives it will use this same helper, so the invariant has one +definition either way. Selecting only the columns you mean to expose - as above +- is still your responsibility until the public field allowlist exists. + ## 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*. + +There is also no generated public API: no public list route, no public detail +route, no public field projection and no cache invalidation. Those are the next +PRs, and [Limitations](/docs/dev/content-engine/limitations) tracks what is +still missing. diff --git a/apps/docs/content/docs/dev/content-engine/service.mdx b/apps/docs/content/docs/dev/content-engine/service.mdx index 6e014e46e..26cd4743c 100644 --- a/apps/docs/content/docs/dev/content-engine/service.mdx +++ b/apps/docs/content/docs/dev/content-engine/service.mdx @@ -230,7 +230,7 @@ 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, no event +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 ``` @@ -239,6 +239,11 @@ result?.row; // the full row 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). +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 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 567544053..095f08b22 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -274,12 +274,16 @@ core event - `changedFields` narrows to that content type's own field names. /> 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, a no-op update -and a no-op publish all emit nothing. - -Exactly one event fires per mutation - `published` and `unpublished` never come -with an `updated` alongside them. +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/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index d5d952aeb..4887c9cc0 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -15,6 +15,27 @@ 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; diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 84d3233fb..923f9081c 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -36,6 +36,7 @@ export { CONTENT_PUBLICATION_STATUSES, CONTENT_SYSTEM_FIELDS, CONTENT_TEXT_DEFAULT_LENGTH, + isContentPublicationStatus, RESERVED_FILTER_KEYS, } from "./const"; export { defineContentType } from "./define"; diff --git a/packages/vitnode/src/content/publication.test-d.ts b/packages/vitnode/src/content/publication.test-d.ts index 6a64f0801..214d84abf 100644 --- a/packages/vitnode/src/content/publication.test-d.ts +++ b/packages/vitnode/src/content/publication.test-d.ts @@ -1,5 +1,7 @@ import { assertType, describe, expectTypeOf, it } from "vitest"; +import type { testCategoryContentType } from "@/tests/content-fixtures"; + import { testArticleContentType, testPostContentType, @@ -8,6 +10,8 @@ import { import type { AnyContentTypeDefinition, ContentCreateInput, + ContentFilterInput, + ContentOrderableFieldName, ContentPublicationStatus, ContentSelect, ContentUpdateInput, @@ -18,6 +22,7 @@ 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 @@ -54,6 +59,11 @@ describe("publication", () => { 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(); @@ -160,4 +170,47 @@ describe("publication", () => { }); }); }); + + // 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" + | "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/server/publication.test.ts b/packages/vitnode/src/content/server/publication.test.ts new file mode 100644 index 000000000..79673a54b --- /dev/null +++ b/packages/vitnode/src/content/server/publication.test.ts @@ -0,0 +1,69 @@ +// @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 { 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("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 index 708011c7d..1e3dba678 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -9,13 +9,36 @@ import type { ContentPublicationMethods, ContentService } from "./service"; import { ContentEngineError } from "../errors"; /** - * The one definition of "published", applied centrally so no caller has to - * remember it. + * The one definition of "published". + * + * ```sql + * status = 'published' AND published_at IS NOT NULL AND published_at <= NOW() + * ``` + * + * Nothing in the engine generates a public route yet, so today this exists for + * **hand-written plugin queries** - the supported way to expose published + * content while the public read layer is still being built: + * + * ```ts + * const rows = await c + * .get("db") + * .select({ id: articles.table.id, title: articles.table.title }) + * .from(articles.table) + * .where(publishedCondition(articles.columns)); + * ``` + * + * It is exported rather than kept internal because writing that predicate by + * hand is exactly the thing worth getting wrong once: forget the `IS NOT NULL` + * and a row whose timestamp was cleared leaks. The generated public service in + * a later PR will consume this same helper, so the invariant has one definition + * either way. * * `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. `IS NOT NULL` is what keeps a row - * whose timestamp was cleared by hand out of public results. + * publishing a purely additive change later. + * + * Enabling `publication` does not expose anything publicly on its own. It adds + * the lifecycle; serving it is still your route. */ export const publishedCondition = ( columns: Record, 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 9b77b4283..e9bf197ef 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"; @@ -72,9 +74,19 @@ export const buildFilterCondition = ({ for (const [name, raw] of Object.entries(filters)) { if (raw === undefined) continue; - // `status` is a generated column, not a declared field, so it has no - // descriptor to check - the schema's `z.enum` already narrowed the value. + // `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; } 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/types.ts b/packages/vitnode/src/content/types.ts index 8ba33a3ed..f086a0e67 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -500,9 +500,18 @@ 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< From 8afaae47cdc42c64698a510581da22cee288a0fa Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 15:26:30 +0200 Subject: [PATCH 03/13] fix: Narrow publishedCondition publication columns `publishedCondition` took `Record`, so passing the columns of a content type without publication compiled and then compared columns that do not exist. It now takes the two columns it actually reads, which a `ContentModel`'s column map satisfies only when `publication` is enabled. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/content/server/publication.test-d.ts | 51 +++++++++++++++++++ .../vitnode/src/content/server/publication.ts | 16 +++++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/vitnode/src/content/server/publication.test-d.ts 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.ts b/packages/vitnode/src/content/server/publication.ts index 1e3dba678..9a92345f1 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -8,6 +8,20 @@ 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. + */ +interface PublicationColumns { + publishedAt: PgColumn; + status: PgColumn; +} + /** * The one definition of "published". * @@ -41,7 +55,7 @@ import { ContentEngineError } from "../errors"; * the lifecycle; serving it is still your route. */ export const publishedCondition = ( - columns: Record, + columns: PublicationColumns, ): SQL | undefined => and( eq(columns.status, "published"), From 1c0577ec1a1b40ec2a4427f669c3cc6dc4f6cbe6 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 15:50:37 +0200 Subject: [PATCH 04/13] feat: Add Content Engine slug field `field.slug({ source: "title" })` generates a NOT NULL, unique-indexed varchar and derives its value from a text field when the create payload omits it. Supplied values are normalised the same way, so the rules hold whoever wrote them. An update never re-derives the slug - only an explicit `slug` in the patch moves it, which is what keeps published URLs stable. Nothing auto-suffixes: `slugify` is deterministic, uniqueness belongs to the index, and a clash surfaces as the existing 23505 -> 409 mapping. A slug that folds to nothing (CJK, emoji, punctuation) throws the new `ContentInputError`, which the generated routes turn into a 400 carrying the actionable message. The example plugin gains a slug on `example.article`, with the two-step backfill migration a populated table actually needs. Co-Authored-By: Claude Opus 5 (1M context) --- .../0024_add_example_article_slug.sql | 30 + apps/docs/migrations/meta/0024_snapshot.json | 2535 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + packages/vitnode/src/content/admin/spec.ts | 12 +- packages/vitnode/src/content/const.ts | 8 + packages/vitnode/src/content/define.test.ts | 117 +- packages/vitnode/src/content/define.ts | 61 +- packages/vitnode/src/content/errors.ts | 20 + packages/vitnode/src/content/fields.ts | 35 + packages/vitnode/src/content/index.ts | 6 +- packages/vitnode/src/content/indexes.test.ts | 47 + packages/vitnode/src/content/indexes.ts | 12 +- .../vitnode/src/content/publication.test-d.ts | 1 + packages/vitnode/src/content/schemas.test.ts | 75 + packages/vitnode/src/content/schemas.ts | 9 + .../src/content/server/column-builders.ts | 7 + .../src/content/server/http-errors.test.ts | 39 + .../vitnode/src/content/server/http-errors.ts | 8 + .../src/content/server/service.test.ts | 141 +- .../vitnode/src/content/server/service.ts | 117 +- packages/vitnode/src/content/slug.test-d.ts | 121 + packages/vitnode/src/content/slug.test.ts | 75 + packages/vitnode/src/content/slug.ts | 59 + packages/vitnode/src/content/types.ts | 30 + .../vitnode/src/tests/content-fixtures.ts | 6 +- plugins/example/src/const.ts | 1 + plugins/example/src/content/article.ts | 6 +- plugins/example/src/database/postgres.test.ts | 101 + plugins/example/src/database/tables.test.ts | 48 +- 29 files changed, 3717 insertions(+), 17 deletions(-) create mode 100644 apps/docs/migrations/0024_add_example_article_slug.sql create mode 100644 apps/docs/migrations/meta/0024_snapshot.json create mode 100644 packages/vitnode/src/content/slug.test-d.ts create mode 100644 packages/vitnode/src/content/slug.test.ts create mode 100644 packages/vitnode/src/content/slug.ts 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..9dab665b4 --- /dev/null +++ b/apps/docs/migrations/0024_add_example_article_slug.sql @@ -0,0 +1,30 @@ +-- `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 +-- Two rows can share a title, and a title in a non-Latin script normalises to +-- nothing at all. Both keep their row id as a deterministic tie-breaker - no +-- title is overwritten and no row is dropped. `concat_ws` skips the NULL, so a +-- row with no usable title becomes just its id. +UPDATE "example_articles" AS a +SET "slug" = concat_ws('-', a."slug", a."id") +WHERE a."slug" IS NULL + OR EXISTS ( + SELECT 1 FROM "example_articles" AS b + WHERE b."slug" = a."slug" AND b."id" <> 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/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 25ba85303..74d9a35a7 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "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/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index 05f55be93..d153202b1 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -121,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 { @@ -221,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/const.ts b/packages/vitnode/src/content/const.ts index 4887c9cc0..4ce991007 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -53,6 +53,7 @@ export const CONTENT_FILTERABLE_FIELD_KINDS = [ "enum", "number", "relation", + "slug", "text", "user", ] as const; @@ -101,6 +102,13 @@ export const CONTENT_IDENTIFIER_MAX_LENGTH = 63; 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; 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 ebbeff815..c1c183466 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -22,11 +22,25 @@ 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; @@ -43,6 +57,9 @@ 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; }; @@ -80,6 +97,7 @@ const FIELD_KINDS = new Set([ "enum", "number", "relation", + "slug", "text", "textarea", "user", @@ -144,6 +162,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) { @@ -188,6 +216,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, @@ -226,11 +281,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 }, ); } @@ -362,6 +417,8 @@ export const defineContentType = < assertField(id, name, fieldMap[name]); } + assertSlugSources(id, fieldMap); + const knownColumns = new Set([ ...fieldNames, ...systemFields, 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/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/index.ts b/packages/vitnode/src/content/index.ts index 923f9081c..594ff24cf 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -34,13 +34,14 @@ export { 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, @@ -66,6 +67,7 @@ export { export type { RegisteredContentType } from "./registry"; export { buildContentSchemas } from "./schemas"; export type { ContentSchemas } from "./schemas"; +export { slugify } from "./slug"; export type { AnyContentTypeDefinition, ContentAdminConfig, @@ -94,6 +96,8 @@ export type { ContentReferenceFieldName, ContentRelationField, ContentSelect, + ContentSlugField, + ContentSlugRequired, ContentSystemField, ContentTextareaField, ContentTextField, 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 0041a6db5..dd36e74e2 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -124,7 +124,8 @@ const named = ( * 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, * 5. `(status, publishedAt)` when publication is enabled - one composite index @@ -182,9 +183,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( diff --git a/packages/vitnode/src/content/publication.test-d.ts b/packages/vitnode/src/content/publication.test-d.ts index 214d84abf..b4db06a51 100644 --- a/packages/vitnode/src/content/publication.test-d.ts +++ b/packages/vitnode/src/content/publication.test-d.ts @@ -183,6 +183,7 @@ describe("publication", () => { | "excerpt" | "id" | "publishedAt" + | "slug" | "status" | "title" | "updatedAt" 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 e53870bd9..ae82c1329 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -13,6 +13,7 @@ import type { import { CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, + CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_SYSTEM_FIELDS, isFilterableFieldKind, } from "./const"; @@ -90,6 +91,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); @@ -122,6 +130,7 @@ const applyPresence = ( if ( fieldValue.kind !== "dateTime" && fieldValue.kind !== "relation" && + fieldValue.kind !== "slug" && fieldValue.kind !== "user" && fieldValue.defaultValue !== undefined ) { diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index adc25774c..cb951e9d8 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -16,6 +16,7 @@ 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"; @@ -143,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/http-errors.test.ts b/packages/vitnode/src/content/server/http-errors.test.ts index e97856e7e..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) => @@ -86,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 6a2bb9960..2f00c4229 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -1,6 +1,8 @@ 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"; @@ -43,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, { diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts index 27adae4da..8123e566a 100644 --- a/packages/vitnode/src/content/server/service.test.ts +++ b/packages/vitnode/src/content/server/service.test.ts @@ -16,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; @@ -549,4 +551,141 @@ describe("content service", () => { ); }); }); + + 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 52ec99762..342b60ab5 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -14,6 +14,7 @@ import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, ContentCreateInput, + ContentFieldMap, ContentFieldName, ContentFilterInput, ContentOrderableFieldName, @@ -27,9 +28,11 @@ import { CONTENT_DEFAULT_PAGE_SIZE, CONTENT_OPTIONS_LIMIT, CONTENT_PUBLICATION_FIELDS, + CONTENT_SLUG_DEFAULT_LENGTH, } from "../const"; -import { ContentEngineError } from "../errors"; +import { ContentEngineError, ContentInputError } from "../errors"; import { orderableColumns } from "../registry"; +import { slugify } from "../slug"; import { buildFilterCondition, buildOrderColumn, @@ -153,6 +156,29 @@ export interface ContentServiceBase { ) => Promise | null>; } +interface SlugFieldConfig { + maxLength: number; + name: string; + /** Field the value is derived from when a create payload omits the slug. */ + source: string | undefined; +} + +const slugFieldsOf = (fields: ContentFieldMap): SlugFieldConfig[] => { + const slugFields: SlugFieldConfig[] = []; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "slug") continue; + + slugFields.push({ + maxLength: fieldValue.maxLength ?? CONTENT_SLUG_DEFAULT_LENGTH, + name, + source: fieldValue.source, + }); + } + + return slugFields; +}; + interface ReferenceTarget { /** Aliased, so two relations pointing at the same table can both be joined. */ aliased: PgTable; @@ -280,6 +306,89 @@ export const createContentService = < 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"); @@ -398,7 +507,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); @@ -519,7 +628,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); 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 f086a0e67..2a2b1bed8 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -57,6 +57,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, @@ -141,6 +170,7 @@ export type ContentFieldDescriptor = | ContentEnumField | ContentNumberField | ContentRelationField + | ContentSlugField | ContentTextareaField | ContentTextField | ContentUserField; diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index a72e9789b..3f626c974 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -55,12 +55,16 @@ export const testArticleContentType = defineContentType({ }, }); -/** The Stage 2 shape: `status` and `publishedAt` come from `publication`. */ +/** + * 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(), diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index e78557d5c..295c58e5e 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -12,4 +12,5 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/example" as const }; 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 6fddef169..b3400f124 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -21,6 +21,9 @@ 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 }), @@ -47,6 +50,7 @@ export const articleContentType = defineContentType({ columns: [ "status", "title", + "slug", "code", "category", "author", @@ -54,7 +58,7 @@ export const articleContentType = defineContentType({ "updatedAt", ], searchableFields: ["title", "code", "excerpt"], - orderableFields: ["title", "code"], + 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 79655809d..3d1f96cac 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -318,6 +318,107 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { 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("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 11bd7fdb4..4911e08c6 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -61,11 +61,21 @@ describe("example_articles", () => { code: "varchar(100)", // text, unique excerpt: "text", // textarea featured: "boolean", + slug: "varchar(160)", // slug title: "varchar(200)", // text views: "integer", // number }); }); + 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]), @@ -81,8 +91,12 @@ describe("example_articles", () => { expect(columns.publishedAt.default).toBeUndefined(); }); - it("gives the unique text field a unique index, and nothing else one", () => { - expect(uniqueIndexNames(articles)).toEqual(["example_articles_code_key"]); + 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", () => { @@ -91,6 +105,7 @@ describe("example_articles", () => { "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. @@ -165,6 +180,35 @@ 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 no random suffix: every row keeps whatever its + // title gave it, and only the collisions gain the id. + expect(migration).toContain(`concat_ws('-', a."slug", a."id")`); + }); + }); + 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)`, From 86026567d302d0ad3aea3097fc45a5c2eaa16b61 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 16:06:39 +0200 Subject: [PATCH 05/13] feat: Add public Content Type configuration and projection `publicApi: { enabled: true, path, fields }` opts a content type into a generated public read surface. It requires `publication` and exactly one exposed slug field, both checked at definition time, so enabling publication still exposes nothing on its own. `fields` is a strict allowlist with no wildcard, and `searchableFields`, `filterableFields` and `orderableFields` must each be a subset of it - that is what stops a filter or a sort being used to probe a column the response omits. User fields are rejected outright (a user field resolves to a person), and so is `status`, which is a constant once every row is published. Paths are validated as a single lowercase segment, `admin` is reserved because the admin gate is a substring test, and two content types claiming the same path fail at registry validation naming both plugins. `ContentPublicSelect` carries exactly the allowlisted keys, with an exposed relation projected to `{ id, label }`. The matching Zod schemas are what the public SELECT will be built from, so a private field never leaves Postgres rather than being fetched and deleted. Named `publicApi` rather than `public`: the latter is a reserved word in strict mode and cannot be destructured in `defineContentType`. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/const.ts | 60 ++++ packages/vitnode/src/content/define.ts | 242 ++++++++++++- packages/vitnode/src/content/index.ts | 18 + packages/vitnode/src/content/public.test-d.ts | 147 ++++++++ packages/vitnode/src/content/public.test.ts | 332 ++++++++++++++++++ packages/vitnode/src/content/registry.test.ts | 79 +++++ packages/vitnode/src/content/registry.ts | 33 +- packages/vitnode/src/content/schemas.ts | 97 ++++- packages/vitnode/src/content/types.ts | 157 ++++++++- .../vitnode/src/tests/content-fixtures.ts | 12 + plugins/example/src/content/article.ts | 15 + 11 files changed, 1187 insertions(+), 5 deletions(-) create mode 100644 packages/vitnode/src/content/public.test-d.ts create mode 100644 packages/vitnode/src/content/public.test.ts diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 4ce991007..9a13f45d4 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -112,6 +112,66 @@ export const CONTENT_SLUG_DEFAULT_LENGTH = 160; export const CONTENT_DEFAULT_PAGE_SIZE = 25; export const CONTENT_OPTIONS_LIMIT = 25; +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 }`. diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index c1c183466..39f3232d2 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -4,19 +4,30 @@ 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"; @@ -351,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 @@ -361,11 +563,14 @@ export const defineContentType = < TId extends string, TFields extends ContentFieldsConstraint, TPublication extends boolean = false, + TPublicField extends ContentPublicExposableField = never, + TPublicEnabled extends boolean = false, >({ admin, fields, id, indexes = [], + publicApi, publication, tableName, }: { @@ -373,10 +578,22 @@ export const defineContentType = < fields: TFields; id: TId; 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).`, @@ -448,6 +665,16 @@ 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, @@ -457,11 +684,22 @@ export const defineContentType = < publication: { enabled: publicationEnabled as TPublication, }, + publicApi: resolvedPublicApi as ResolvedContentPublicApiConfig< + TPublicField, + TPublicEnabled + >, schemas: buildContentSchemas< - ContentTypeDefinition + ContentTypeDefinition< + TId, + TFields, + TPublication, + TPublicField, + TPublicEnabled + > >({ admin: resolvedAdmin, fields: fieldMap, + publicApi: resolvedPublicApi, publication: publicationEnabled, }), tableName, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 594ff24cf..4904fee8c 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -31,6 +31,14 @@ export { 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, @@ -61,6 +69,7 @@ export { findContentTypeById, orderableColumns, pathToContentTypeId, + publicOrderableColumns, validateContentTypes, withContentPermissions, } from "./registry"; @@ -89,9 +98,17 @@ export type { ContentNumberField, ContentOnDelete, ContentOrderableFieldName, + ContentPublicApiConfig, ContentPublicationConfig, ContentPublicationField, ContentPublicationStatus, + ContentPublicExposableField, + ContentPublicFieldName, + ContentPublicFilterInput, + ContentPublicListRow, + ContentPublicOrderableFieldName, + ContentPublicRelation, + ContentPublicSelect, ContentReferenceField, ContentReferenceFieldName, ContentRelationField, @@ -108,5 +125,6 @@ export type { FilterableContentFieldName, ResolvedContentAdminConfig, ResolvedContentIndex, + ResolvedContentPublicApiConfig, ResolvedContentPublicationConfig, } from "./types"; 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..bdd6574cc --- /dev/null +++ b/packages/vitnode/src/content/public.test-d.ts @@ -0,0 +1,147 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import type { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import { testPostContentType } from "@/tests/content-fixtures"; + +import type { + AnyContentTypeDefinition, + ContentPublicFieldName, + ContentPublicFilterInput, + ContentPublicListRow, + ContentPublicRelation, + ContentPublicSelect, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +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 id and a label", () => { + // `category` is required, so it is never null - and it is never the + // related row either. + expectTypeOf< + ContentPublicSelect["category"] + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + id: number; + label: null | string; + }>(); + }); + + 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", + }); + }); + }); +}); diff --git a/packages/vitnode/src/content/public.test.ts b/packages/vitnode/src/content/public.test.ts new file mode 100644 index 000000000..8dc3be340 --- /dev/null +++ b/packages/vitnode/src/content/public.test.ts @@ -0,0 +1,332 @@ +// @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 id and a label", () => { + // Read back through the whole object: `shape[...]` is typed as the base + // Zod interface, which has no `safeParse`. + const row = { + category: { id: 3, label: "News" }, + excerpt: null, + publishedAt: new Date(), + slug: "hello", + title: "Hello", + }; + + expect(schemas.publicSelectObject.safeParse(row).success).toBe(true); + // Not the related row - one level, two keys, no population. + expect( + schemas.publicSelectObject.safeParse({ ...row, category: 3 }).success, + ).toBe(false); + }); + + 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/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index f7da4d28e..e6dad7e13 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,80 @@ 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 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("names both plugins and 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/second"), + ]), + ).toThrow( + /@acme\/first -> test\.one.*@acme\/second -> test\.two|@acme\/second -> 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 9b1cabd7b..6ed2cf83c 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -39,7 +39,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. + * table name, two content types resolving to the same Postgres index name, or + * two of them claiming the same public path. */ export const validateContentTypes = ( entries: RegisteredContentType[], @@ -47,6 +48,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) { @@ -84,6 +86,23 @@ export const validateContentTypes = ( assertFilterKeys(definition); + // Public paths are checked across *every* plugin, not per plugin. Routes + // are mounted under `/api/{pluginId}/...`, so two plugins claiming + // "articles" would not actually collide at the router - but two content + // types answering to the same public path is ambiguous for anyone reading + // the API, and refusing it keeps the public surface one flat namespace. + if (definition.publicApi.enabled) { + const path = definition.publicApi.path; + const duplicatePath = byPublicPath.get(path); + 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(path, 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. @@ -210,3 +229,15 @@ export const orderableColumns = ( ...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.ts b/packages/vitnode/src/content/schemas.ts index ae82c1329..92a238939 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -5,12 +5,15 @@ import type { ContentCreateInput, ContentFieldDescriptor, ContentFieldMap, + ContentPublicSelect, ContentSelect, ContentUpdateInput, ResolvedContentAdminConfig, + ResolvedContentPublicApiConfig, } from "./types"; import { + CONTENT_PUBLIC_ALWAYS_ORDERABLE, CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, CONTENT_SLUG_DEFAULT_LENGTH, @@ -18,6 +21,19 @@ import { 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. */ create: z.ZodType>; @@ -36,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>; /** @@ -213,18 +242,52 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => }), ); +/** An exposed relation comes back as an identifier and a display label. */ +const publicRelationSchema = (): z.ZodObject => + z.object({ id: z.number(), label: z.string().nullable() }); + +/** + * 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 two pieces it needs rather than a whole definition, so + * 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); @@ -268,6 +331,19 @@ export const buildContentSchemas = ({ ]; 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 - @@ -286,6 +362,25 @@ export const buildContentSchemas = ({ 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/types.ts b/packages/vitnode/src/content/types.ts index 2a2b1bed8..789121bda 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,5 +1,6 @@ import type { CONTENT_FILTERABLE_FIELD_KINDS, + CONTENT_PUBLIC_EXPOSABLE_COLUMNS, CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, CONTENT_SYSTEM_FIELDS, @@ -422,6 +423,67 @@ type ContentPublicationColumns = TDefinition extends { ? { 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 // --------------------------------------------------------------------------- @@ -430,6 +492,8 @@ 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; @@ -438,9 +502,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; } @@ -548,3 +621,85 @@ export type ContentReferenceFieldName = FieldNamesOfKind< TDefinition, "relation" | "user" >; + +// --------------------------------------------------------------------------- +// Public projection +// --------------------------------------------------------------------------- + +/** + * How an exposed `relation` comes back: an identifier and the target's own + * `admin.titleField`, and nothing else. + * + * Deliberately not the related row. Deep nesting and arbitrary population are + * out of scope - they are the point at which a REST projection turns into + * GraphQL, and a hand-written route is the better answer. + */ +export interface ContentPublicRelation { + id: number; + label: null | string; +} + +/** 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/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 3f626c974..162879350 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -75,6 +75,18 @@ export const testPostContentType = defineContentType({ }), }, 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", diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index b3400f124..e6eaaec3c 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -39,6 +39,21 @@ 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"] }], From cc5b3df0aa8f5f98c48396e36c83d635cd8d2fd5 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 16:46:14 +0200 Subject: [PATCH 06/13] feat: Add public read-only Content Service `model.publicService(c)` is a separate object from `model.service`, not a filtered view of it: there is no create, update, delete, publish or unpublish to omit, so a public write is not something you can reach by accident. It is `undefined` unless the content type has a `publicApi`. Two invariants make it safe. The published predicate is applied inside every method rather than passed in, so there is no argument a caller can forget - `ContentPublicFindManyArgs` has no `where` and no `includeDrafts`. And the SELECT is built from `publicApi.fields`, so a private column never leaves Postgres. The single exception is `id`, which the pagination cursor reads off the row; it is dropped from the projection unless the allowlist names it, and that boundary is tested. Filters, search and ordering each go through the public allowlist rather than the admin one, so a column that is readable is not automatically queryable and a private column cannot be probed sideways. `resolveReferenceTargets` moves to `server/references.ts` so both services resolve relation labels identically, and `publicationColumns` narrows an erased column map for the predicate - the generic case Phase 0's narrower parameter type made explicit. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/server/index.ts | 14 +- packages/vitnode/src/content/server/model.ts | 15 + .../src/content/server/public-service.test.ts | 378 ++++++++++++++++++ .../src/content/server/public-service.ts | 262 ++++++++++++ .../src/content/server/publication.test.ts | 31 +- .../vitnode/src/content/server/publication.ts | 28 +- packages/vitnode/src/content/server/query.ts | 14 + .../vitnode/src/content/server/references.ts | 96 +++++ .../vitnode/src/content/server/service.ts | 83 +--- plugins/example/src/database/postgres.test.ts | 123 ++++++ 10 files changed, 959 insertions(+), 85 deletions(-) create mode 100644 packages/vitnode/src/content/server/public-service.test.ts create mode 100644 packages/vitnode/src/content/server/public-service.ts create mode 100644 packages/vitnode/src/content/server/references.ts diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 210004da3..2bd38b9c5 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -17,7 +17,17 @@ export { rethrowAsHttpError, withHttpErrors } from "./http-errors"; export { createContentModel } from "./model"; export type { ContentModel } from "./model"; export { buildContentAdminModule } from "./module"; -export { publicationMethods, publishedCondition } from "./publication"; +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, @@ -26,6 +36,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 { 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-service.test.ts b/packages/vitnode/src/content/server/public-service.test.ts new file mode 100644 index 000000000..03e2dc5bf --- /dev/null +++ b/packages/vitnode/src/content/server/public-service.test.ts @@ -0,0 +1,378 @@ +// @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, + label__category: "News", + 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", + "label__category", + "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 to an id and a label", async () => { + const { service } = publicService([[storedRow]]); + + expect(await service.findBySlug("hello-world")).toMatchObject({ + category: { id: 3, label: "News" }, + }); + }); + + it("joins once per exposed relation, and not for anything else", async () => { + const { calls, service } = publicService([[storedRow]]); + + await service.findBySlug("hello-world"); + + // `author` is a user field and is not exposed, so it costs no join. + expect(opsOf(calls, "leftJoin")).toHaveLength(1); + }); + + 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..b5cad9273 --- /dev/null +++ b/packages/vitnode/src/content/server/public-service.ts @@ -0,0 +1,262 @@ +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"; +import { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; + +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. + */ +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 references = resolveReferenceTargets(definition, table, columns); + const exposed = publicApi.fields; + const exposesId = exposed.includes("id"); + // Only the relations the allowlist names: a `user` field is never exposable, + // and an unexposed relation should not cost a join either. + const exposedRelations = exposed.filter( + name => fields[name]?.kind === "relation" && references[name], + ); + 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]])), + ...Object.fromEntries( + exposedRelations.map(name => [ + `${LABEL_PREFIX}${name}`, + references[name].labelColumn, + ]), + ), + }); + + /** + * Turns one raw row into the public projection: relations collapse to + * `{ id, label }`, the label columns disappear, and `id` goes with them + * unless it was asked for. + */ + const project = ( + row: Record, + ): ContentPublicSelect => { + const projected: Record = {}; + + for (const name of exposed) { + if (!exposedRelations.includes(name)) { + projected[name] = row[name]; + continue; + } + + const id = row[name]; + projected[name] = + typeof id === "number" + ? { id, label: toLabel(row[`${LABEL_PREFIX}${name}`]) } + : null; + } + + if (exposesId) projected.id = row.id; + + return projected as ContentPublicSelect; + }; + + const readOne = async ( + condition: SQL, + ): Promise | null> => { + let builder = c.get("db").select(selection()).from(table).$dynamic(); + + for (const name of exposedRelations) { + const target = references[name]; + builder = builder.leftJoin( + target.aliased, + eq(target.owner, target.idColumn), + ); + } + + const [row] = await builder + .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 }) => { + let builder = c.get("db").select(selection()).from(table).$dynamic(); + + for (const name of exposedRelations) { + const target = references[name]; + builder = builder.leftJoin( + target.aliased, + eq(target.owner, target.idColumn), + ); + } + + return await builder + .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.ts b/packages/vitnode/src/content/server/publication.test.ts index 79673a54b..16346e30b 100644 --- a/packages/vitnode/src/content/server/publication.test.ts +++ b/packages/vitnode/src/content/server/publication.test.ts @@ -11,7 +11,11 @@ import { import { ContentEngineError } from "../errors"; import { createContentModel } from "./model"; -import { publicationMethods, publishedCondition } from "./publication"; +import { + publicationColumns, + publicationMethods, + publishedCondition, +} from "./publication"; const categories = createContentModel(testCategoryContentType); const posts = createContentModel(testPostContentType, { @@ -47,6 +51,31 @@ describe("publishedCondition", () => { }); }); +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); diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts index 9a92345f1..f7e8bc519 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -17,11 +17,37 @@ import { ContentEngineError } from "../errors"; * publication is therefore a compile error rather than a query against columns * that do not exist. */ -interface PublicationColumns { +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". * diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index e9bf197ef..2659e96bf 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -56,12 +56,19 @@ 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; @@ -74,6 +81,13 @@ export const buildFilterCondition = ({ 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 diff --git a/packages/vitnode/src/content/server/references.ts b/packages/vitnode/src/content/server/references.ts new file mode 100644 index 000000000..887ce998a --- /dev/null +++ b/packages/vitnode/src/content/server/references.ts @@ -0,0 +1,96 @@ +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. + * + * Shared by the admin service (which joins every reference for its `labels` + * object) and the public one (which joins only the exposed relations), so both + * resolve a label exactly the same way. + */ +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/service.ts b/packages/vitnode/src/content/server/service.ts index 342b60ab5..dd491d5a7 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -1,14 +1,12 @@ 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, ne, sql } from "drizzle-orm"; -import { alias, getTableConfig } from "drizzle-orm/pg-core"; import type { ContentSchemas } from "../schemas"; import type { @@ -40,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; @@ -179,86 +178,6 @@ const slugFieldsOf = (fields: ContentFieldMap): SlugFieldConfig[] => { return slugFields; }; -interface ReferenceTarget { - /** Aliased, so two relations pointing at the same table can both be joined. */ - aliased: PgTable; - idColumn: PgColumn; - labelColumn: PgColumn; - owner: PgColumn; -} - -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 = {}; - - 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; -}; - /** * A typed repository bound to one request's database handle. * diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index 3d1f96cac..0a2c21dc8 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -419,6 +419,129 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { 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" + `; + const category = await categories.create({ name: "Public" }); + + 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, label: "Public" }, + excerpt: "A summary", + title: "Hello public world", + }); + // Fetched for the cursor, dropped from the projection. + expect(detail).not.toHaveProperty("id"); + + // 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 From bf64d100ebd19624b4a8b21dca582f56b856909d Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 16:54:52 +0200 Subject: [PATCH 07/13] feat: Add generated public content routes Two GET routes per public content type, mounted by a top-level `buildContentPublicModule`: GET /api/{pluginId}/content/{publicApi.path}/ GET /api/{pluginId}/content/{publicApi.path}/{slug} Public by omission, exactly like every other public route in VitNode: no `adminStaffPermission` and no `/admin/` in the path, so the global admin gate never sees them. The route tests install no session at all, which is what makes "it answered anyway" a real assertion. A draft, an unpublished row, a cleared publication date and a typo are all the same 404 - a 403 would confirm the record exists. `orderBy` is a literal enum of the public allowlist, so a column that is orderable in the AdminCP but not published is a 400. Private fields are absent from the filter schema entirely, so they cannot reach the query builder even as a rejected value. The module deliberately registers no `contentTypes`: `buildApiPlugin` collects them recursively and a second registration would throw "Duplicate content type id". That trap has its own test. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/server/index.ts | 2 + .../src/content/server/public-module.ts | 60 ++++ .../src/content/server/public-routes.test.ts | 314 ++++++++++++++++++ .../src/content/server/public-routes.ts | 157 +++++++++ plugins/example/src/config.api.ts | 19 +- 5 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 packages/vitnode/src/content/server/public-module.ts create mode 100644 packages/vitnode/src/content/server/public-routes.test.ts create mode 100644 packages/vitnode/src/content/server/public-routes.ts diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 2bd38b9c5..9cdad1903 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -17,6 +17,8 @@ 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, 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..53b937ca1 --- /dev/null +++ b/packages/vitnode/src/content/server/public-routes.test.ts @@ -0,0 +1,314 @@ +// @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, label: "News" }, + 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 id and a label", 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, label: "News" }); + }); + + 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/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts index e459b908c..13a798b67 100644 --- a/plugins/example/src/config.api.ts +++ b/plugins/example/src/config.api.ts @@ -1,16 +1,33 @@ import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; +import { buildContentPublicModule } from "@vitnode/core/content/server"; import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; +import { articleContent } from "@/database/articles"; +import { categoryContent } from "@/database/categories"; import "@/api/lib/events"; /** * No `contentTypes` here: `buildApiPlugin` walks the module tree, so the * content types declared in `admin.module.ts` also drive the registry and the * derived `can_view` / `can_create` / `can_edit` / `can_delete` permissions. + * + * `buildContentPublicModule` is top-level on purpose - its paths must stay out + * of `/admin/`, which the global admin gate matches as a substring. It skips + * any content type without `publicApi`, so `categoryContent` contributes + * nothing, and it registers no content types of its own (that would be a + * duplicate registration). + * + * Public routes land at `/api/@vitnode/example/content/articles/`. */ export const exampleApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, - modules: [adminModule], + modules: [ + adminModule, + buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [articleContent, categoryContent], + }), + ], }); From 78acabc702a896ba280ed00755bd5a88a7b958ce Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 17:02:34 +0200 Subject: [PATCH 08/13] feat: Add AdminCP publication actions One row action that flips with the row's state - Publish for a draft, Unpublish for a published row - rather than two buttons with a dead one. Gated by `can_publish`, never by `can_edit`, so a role can be trusted to write drafts without being trusted to put them on the internet. Confirmation dialog, loading and disabled state from the existing `ConfirmActionAlertDialog` (which now takes `submitVariant`, since `destructive` is right for a delete and wrong for a publish), success and failure toasts with a description, and a table refresh through the server action's `revalidatePath`. Both routes are idempotent, so a double click is a 200 with `changed: false` rather than an error - the button needs no guard. The edit dialog gets a read-only status line rather than a second publish control: `status` and `publishedAt` are not in the form schema, and two competing mutation paths in one dialog is how a form ends up fighting its own state. Co-Authored-By: Claude Opus 5 (1M context) --- .../confirm-action-alert-dialog.tsx | 7 +- .../src/components/confirm-action/content.tsx | 8 +- .../views/content/actions/content-form.tsx | 118 ++++++--- .../content/actions/mutation-api.server.ts | 42 +++ .../content/actions/publish-action.test.tsx | 239 ++++++++++++++++++ .../views/content/actions/publish-action.tsx | 119 +++++++++ .../content/table/content-table-view.tsx | 16 +- 7 files changed, 513 insertions(+), 36 deletions(-) create mode 100644 packages/vitnode/src/views/admin/views/content/actions/publish-action.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/publish-action.tsx 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/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..f972da601 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 @@ -97,6 +97,48 @@ export const deleteContentAction = async ( 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, + }); + + if (result.status !== 200) { + return { error: result.error ?? "", status: result.status }; + } + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + 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/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/content-table-view.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx index 350df543b..48630d977 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 @@ -13,6 +13,7 @@ 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({ @@ -101,7 +102,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" @@ -110,6 +112,17 @@ export const ContentTableView = async ({ return ( <> + {definition.publication.enabled ? ( + + ) : null} Date: Mon, 3 Aug 2026 17:17:01 +0200 Subject: [PATCH 09/13] feat: Add public content cache invalidation Cache tags are pure strings in `@vitnode/core/content`, so an app can tag its own fetches and `"use cache"` functions with exactly the same values and get invalidated alongside the generated pages. `revalidateTag` lives in the new `@vitnode/core/content/next` entrypoint, the only module in the engine that imports `next/*` - `content/` and `content/server/` are loaded by `apps/api` and drizzle-kit, where that throws. A test walks the import graph and asserts the rule rather than trusting it. Format is `content:{contentTypeId}:{scope}[:{key}]`, clamped to Next's 256-character limit with the same FNV-1a fingerprint the index names use (now shared in `content/fingerprint.ts`), so two 160-character slugs cannot collapse onto one tag. `contentInvalidationTags` is pure, so the whole matrix is a table test: a draft created or edited touches nothing, publish/unpublish/delete-once- published expire list + item + slug, and a slug change expires the old URL and the new one. Nothing global is ever invalidated. Invalidation is triggered by the AdminCP server actions, after the write returns - never by the service, which may be inside an uncommitted transaction, may not be running under Next, and owns no request scope. The update path reads the row *before* writing so it knows the slug it is replacing; that read is skipped for content types with no public API. `RawApiFetchArgs["options"]` gains an explicit `next` field: Next's augmentation of the global `RequestInit` is not visible where the package compiles, and widening the shared fetcher beats bypassing it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/package.json | 5 + .../vitnode/src/content/boundaries.test.ts | 91 +++++++ packages/vitnode/src/content/cache.test.ts | 212 ++++++++++++++++ packages/vitnode/src/content/cache.ts | 106 ++++++++ packages/vitnode/src/content/const.ts | 3 + packages/vitnode/src/content/fingerprint.ts | 35 +++ packages/vitnode/src/content/index.ts | 27 +- packages/vitnode/src/content/indexes.ts | 33 +-- .../vitnode/src/content/next/fetch.server.ts | 77 ++++++ packages/vitnode/src/content/next/index.ts | 13 + .../src/content/next/revalidate.server.ts | 29 +++ packages/vitnode/src/lib/fetcher/raw.ts | 13 +- .../content/actions/mutation-api.server.ts | 124 +++++++++ .../content/actions/mutation-api.test.ts | 238 ++++++++++++++++++ 14 files changed, 968 insertions(+), 38 deletions(-) create mode 100644 packages/vitnode/src/content/boundaries.test.ts create mode 100644 packages/vitnode/src/content/cache.test.ts create mode 100644 packages/vitnode/src/content/cache.ts create mode 100644 packages/vitnode/src/content/fingerprint.ts create mode 100644 packages/vitnode/src/content/next/fetch.server.ts create mode 100644 packages/vitnode/src/content/next/index.ts create mode 100644 packages/vitnode/src/content/next/revalidate.server.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts 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/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 9a13f45d4..a325d7e30 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -99,6 +99,9 @@ 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; 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 4904fee8c..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,15 +26,16 @@ 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, @@ -61,6 +71,7 @@ export type { ContentUpdatedPayload, } from "./events"; export { field } from "./fields"; +export { clampWithFingerprint, fingerprint } from "./fingerprint"; export { contentIndexName, toSnakeCase } from "./indexes"; export { contentAdminHref, diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index dd36e74e2..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 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..0164450a8 --- /dev/null +++ b/packages/vitnode/src/content/next/fetch.server.ts @@ -0,0 +1,77 @@ +import "server-only"; +import type { z } from "zod"; + +import type { AnyContentTypeDefinition } 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, tagged for you. + * + * The tags are the same strings {@link contentPublicListTag} and friends + * produce, so `revalidateContent` expires exactly these responses - and an app + * that tags its own fetches with them gets 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. + */ +export const contentPublicFetch = async ({ + definition, + pluginId, + query, + schema, + slug, +}: { + definition: AnyContentTypeDefinition; + 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}`, + // Next augments the global `RequestInit` with `next`, which is why this + // passes straight through the shared fetcher's `options`. + options: { next: { tags } }, + path: slug === undefined ? "/" : `/${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..a4aabfb0b --- /dev/null +++ b/packages/vitnode/src/content/next/index.ts @@ -0,0 +1,13 @@ +/** + * 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"; 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..8b0fc43ad --- /dev/null +++ b/packages/vitnode/src/content/next/revalidate.server.ts @@ -0,0 +1,29 @@ +import "server-only"; +import { revalidateTag } from "next/cache"; + +import type { ContentInvalidationInput } from "../cache"; + +import { contentInvalidationTags } from "../cache"; + +/** + * 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 + * `revalidateTag` to attach to. A direct caller invalidates for itself, after + * it commits. + */ +export const revalidateContent = (input: ContentInvalidationInput): void => { + for (const tag of contentInvalidationTags(input)) { + // The two-argument form: a profile is required for stale-while-revalidate, + // and `max` is right here because a tag is only expired when the underlying + // row actually changed. + revalidateTag(tag, "max"); + } +}; 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/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts index f972da601..04768a5df 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,13 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; +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 +33,87 @@ 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; +}; + +/** 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, + }); +}; + export const createContentAction = async ( contentTypeId: string, values: Record, @@ -40,6 +125,7 @@ export const createContentAction = async ( definition, method: "post", pluginId, + schema: zodRow, }); if (result.status !== 201) { @@ -47,6 +133,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 +147,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 +164,7 @@ export const editContentAction = async ( } revalidatePath(CONTENT_PAGE_PATH, "page"); + invalidate(definition, id, before, result.data); return {}; }; @@ -86,6 +180,7 @@ export const deleteContentAction = async ( method: "delete", path: `/${id}`, pluginId, + schema: zodRow, }); if (result.status !== 200) { @@ -94,6 +189,19 @@ 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, + }); + } + return {}; }; @@ -116,6 +224,7 @@ const publicationAction = async ( method: "post", path: `/${id}/${action}`, pluginId, + schema: zodPublicationResult, }); if (result.status !== 200) { @@ -124,6 +233,21 @@ const publicationAction = async ( 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, + }); + } + return {}; }; 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..161a05651 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AnyContentTypeDefinition } from "@/content/types"; + +import { + testCategoryContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +const revalidated: string[][] = []; +const fetches: { method: string; path?: string }[] = []; +let responses: { data?: unknown; status: number }[] = []; +let definition: AnyContentTypeDefinition = testPostContentType; + +vi.mock("next/cache", () => ({ revalidatePath: () => undefined })); + +vi.mock("@/content/next/revalidate.server", async () => { + // The decision table itself is pure and tested in `content/cache.test.ts`; + // what matters here is the input the server action hands it. + const { contentInvalidationTags } = await import("@/content/cache"); + + return { + revalidateContent: ( + input: Parameters[0], + ) => { + revalidated.push(contentInvalidationTags(input)); + }, + }; +}); + +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 { + 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}`; + +beforeEach(() => { + revalidated.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(revalidated[0]).toEqual([ + LIST, + ITEM, + slugTag("old"), + slugTag("new"), + ]); + }); + + 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(revalidated[0]).toEqual([LIST, ITEM, slugTag("same")]); + }); + + 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(revalidated[0]).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(revalidated).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(revalidated[0]).toEqual([LIST, ITEM, slugTag("hello")]); + }); + + 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(revalidated[0]).toEqual([LIST, ITEM, slugTag("hello")]); + }); + + 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(revalidated).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(revalidated[0]).toEqual([LIST, ITEM, slugTag("hello")]); + }); + + 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(revalidated[0]).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(revalidated[0]).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(revalidated).toEqual([]); + }); +}); From 52cb302e31f347677d8fe40a9b9237d4f2102f4f Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 3 Aug 2026 17:25:13 +0200 Subject: [PATCH 10/13] docs: Complete Content Engine Stage 2 documentation Four new pages - Slug field, Public API, Public Content Service and Caching - and updates to the nine existing ones that were describing a world without them. The four names are now used consistently and kept apart: Admin Content Service, Public Content Service, generated Admin API, generated public API. `service.mdx` is retitled and says which of the two it is. Three claims that were true last week and are not any more: the "publish buttons are not here yet" callout in admincp.mdx, the "no generated public read endpoint" section in limitations.mdx, and the "until the generated public read layer lands" framing in publication.mdx. Every page repeats the rule that matters: publication alone exposes nothing, public content is opt-in twice over, public writes are never generated, and a direct service call emits no event and expires no cache tag. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/dev/content-engine/admincp.mdx | 33 ++- .../docs/dev/content-engine/caching.mdx | 186 +++++++++++++ .../database-and-migrations.mdx | 26 +- .../defining-a-content-type.mdx | 23 ++ .../docs/dev/content-engine/fields.mdx | 21 +- .../content/docs/dev/content-engine/index.mdx | 10 + .../docs/dev/content-engine/limitations.mdx | 63 +++-- .../content/docs/dev/content-engine/meta.json | 4 + .../docs/dev/content-engine/permissions.mdx | 2 +- .../docs/dev/content-engine/public-api.mdx | 249 ++++++++++++++++++ .../dev/content-engine/public-service.mdx | 150 +++++++++++ .../docs/dev/content-engine/publication.mdx | 41 ++- .../docs/dev/content-engine/schemas.mdx | 35 ++- .../docs/dev/content-engine/service.mdx | 43 ++- .../docs/dev/content-engine/slug-field.mdx | 196 ++++++++++++++ 15 files changed, 1034 insertions(+), 48 deletions(-) create mode 100644 apps/docs/content/docs/dev/content-engine/caching.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/public-api.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/public-service.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/slug-field.mdx diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index bdae96811..9df4e9fd5 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -88,12 +88,37 @@ its table with a **status** column, rendered as a badge rather than raw text: `status` is also a generated filter, so `?status=draft` narrows the list. - - Publishing currently happens through the API - `POST /{id}/publish` - or - through `service.publish` from your own code. The row action, its confirmation - dialog and the `can_publish` gating land with the AdminCP publication UX. +### 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..7149b5e20 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -0,0 +1,186 @@ +--- +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` 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. + +## 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, +- `revalidateTag` needs a Next 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 | +| --- | :-: | :-: | :-: | :-: | +| create draft | — | — | — | — | +| update draft | — | — | — | — | +| publish | ✓ | ✓ | — | ✓ | +| update published, same slug | ✓ | ✓ | — | ✓ | +| update published, slug changed | ✓ | ✓ | ✓ | ✓ | +| unpublish | ✓ | ✓ | ✓ | — | +| delete, ever published | ✓ | ✓ | ✓ | — | +| 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. + +### 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, + }); + } +}; +``` + +`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` lives 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 54f3be79d..036f9be75 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 @@ -91,7 +91,9 @@ 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, 5. `(status, publishedAt)` when @@ -223,6 +225,28 @@ 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. + +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/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 a7354fe6b..71e66973d 100644 --- a/apps/docs/content/docs/dev/content-engine/index.mdx +++ b/apps/docs/content/docs/dev/content-engine/index.mdx @@ -44,6 +44,16 @@ That gives you: (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 692c2a516..f0fe5c7b5 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -19,8 +19,12 @@ other 20%, so you find out here rather than halfway through building. | 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 | Not generated yet. Write the route and gate it with `publishedCondition` | -| Public field projection | Select only the columns you mean to expose, by hand | +| 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 | +| Deep relation nesting in a public response | One level, `{ id, label }`. 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 | @@ -93,28 +97,49 @@ 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. -That includes the publish and unpublish routes: 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.** +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. -There is no generated public read endpoint yet - no list route, no detail route, -no field allowlist, no cache invalidation. Serving published content is your own -route today, and the server surface exports the predicate so the one part that -is easy to get wrong is not written by hand: +[`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. -```ts -import { publishedCondition } from "@vitnode/core/content/server"; +Public **writes** are never generated, under any configuration. -.where(publishedCondition(articleContent.columns)) -``` +## Non-Latin titles need an explicit slug -Selecting only the columns you intend to expose is still yours to get right. -[Serving published content](/docs/dev/content-engine/publication#serving-published-content) -has the full example. +`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. Keep your own redirect table if that matters. + +## 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 +`revalidateTag` needs 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 ae9ead94f..53139393c 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -10,6 +10,10 @@ "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 5f1ea6871..3bc4cc111 100644 --- a/apps/docs/content/docs/dev/content-engine/permissions.mdx +++ b/apps/docs/content/docs/dev/content-engine/permissions.mdx @@ -114,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..4c2779a65 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/public-api.mdx @@ -0,0 +1,249 @@ +--- +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 across **every** registered content type, in every plugin. +Routes are plugin-prefixed so two plugins claiming `articles` would not actually +collide at the router - but two content types answering to the same public path +is ambiguous for anyone reading the API, and refusing it keeps the public +surface one flat namespace. The error names both: + +```text +Public path "articles" is claimed by both @acme/first -> first.article +and @acme/second -> second.article. 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 the target's own +`admin.titleField`: + +```jsonc +"category": { "id": 3, "label": "News" } +``` + +One level, two keys. 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. + +## 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, "label": "News" }, + "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. There is no generated numeric-id detail route. + +| 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..ac858a0e3 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/public-service.mdx @@ -0,0 +1,150 @@ +--- +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` costs nothing +and is what an event listener holding a `contentId` needs. + +## 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; label: string | null } +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. + + +## 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. + +## 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 index 0fd55c1b7..2ed5c0f80 100644 --- a/apps/docs/content/docs/dev/content-engine/publication.mdx +++ b/apps/docs/content/docs/dev/content-engine/publication.mdx @@ -212,13 +212,22 @@ the `archived` rows. permission, exactly as before. -Until the generated public read layer lands, serving published content is your -own route - and the one thing worth not writing by hand is the predicate. The -server surface exports it: +To actually publish content to the internet, add +[`publicApi`](/docs/dev/content-engine/public-api). That block generates two +read-only routes and a [Public Content Service](/docs/dev/content-engine/public-service), +both of which apply the published predicate themselves - 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 { publishedCondition } from "@vitnode/core/content/server"; +import { + publicationColumns, + publishedCondition, +} from "@vitnode/core/content/server"; +import { articleContentType } from "@/content/article"; import { articleContent } from "@/database/articles"; handler: async c => { @@ -230,7 +239,11 @@ handler: async c => { publishedAt: articleContent.table.publishedAt, }) .from(articleContent.table) - .where(publishedCondition(articleContent.columns)); + .where( + publishedCondition( + publicationColumns(articleContentType, articleContent.columns), + ), + ); return c.json({ rows }, 200); }; @@ -246,10 +259,15 @@ 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 a supported escape hatch, not a placeholder: when the generated public -Content Service arrives it will use this same helper, so the invariant has one -definition either way. Selecting only the columns you mean to expose - as above -- is still your responsibility until the public field allowlist exists. +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 @@ -258,7 +276,4 @@ published predicate already reads `publishedAt <= now()`, so scheduling can be added later without changing what is stored - but today `publish()` means *now*. -There is also no generated public API: no public list route, no public detail -route, no public field projection and no cache invalidation. Those are the next -PRs, and [Limitations](/docs/dev/content-engine/limitations) tracks what is -still missing. +[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 d5efb2847..6e5cb148e 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 eight 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,28 @@ 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, label }`. + +```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 26cd4743c..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 @@ -240,9 +248,10 @@ 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). -A service call that runs inside your transaction cannot honestly announce -anything until you commit, so the follow-up is yours. +[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 @@ -276,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..27e35b863 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/slug-field.mdx @@ -0,0 +1,196 @@ +--- +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 + +-- Two rows can share a title, and a non-Latin title normalises to nothing. +-- Both keep their row id as a deterministic tie-breaker; no title is lost. +UPDATE "example_articles" AS a +SET "slug" = concat_ws('-', a."slug", a."id") +WHERE a."slug" IS NULL + OR EXISTS ( + SELECT 1 FROM "example_articles" AS b + WHERE b."slug" = a."slug" AND b."id" <> 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"); +``` + +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, and the Postgres integration test replays it - 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. From 1e4738b56c395616082d3c91e1689cd472b19f1b Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 4 Aug 2026 10:33:30 +0200 Subject: [PATCH 11/13] fix: Sort AdminCP tables by every column the API accepts `ContentTableView` passed only `admin.list.orderableFields` to `DataTable`, so `id`, `createdAt`, `updatedAt` and - with publication - `status` and `publishedAt` had no sort control, even though the generated route has always allowed them. Both sides now read `orderableColumns(definition)`, the client-safe helper the route already builds its `orderBy` enum from, so the two lists cannot drift. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/dev/content-engine/admincp.mdx | 27 +++- .../content/table/content-table-view.test.tsx | 122 ++++++++++++++++++ .../content/table/content-table-view.tsx | 8 +- 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 packages/vitnode/src/views/admin/views/content/table/content-table-view.test.tsx diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index 9df4e9fd5..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 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 48630d977..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,6 +8,7 @@ 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"; @@ -161,7 +162,12 @@ export const ContentTableView = async ({ edges={data.edges} id={`content-${definition.id}`} order={{ - columns: definition.admin.list.orderableFields, + // The same allowlist the generated route builds its `orderBy` enum + // from, so a header the backend would accept is never left unsortable. + // `admin.list.orderableFields` alone would leave out `id`, `createdAt`, + // `updatedAt` and - when publication is on - `status` and + // `publishedAt`, all of which the API has always allowed. + columns: orderableColumns(definition), defaultOrder: { column: definition.admin.list.defaultOrderBy, order: definition.admin.list.defaultOrder, From 738838db55cdd2f4c918b191509d14195175ebda Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 4 Aug 2026 10:33:50 +0200 Subject: [PATCH 12/13] fix: Harden the public content surface for review Ten findings from the PR #733 review, verified against the installed Next 16.3.0-preview.9 declarations rather than assumed. Caching - `contentPublicFetch` now sends `cache: "force-cache"`. Caching is opt-in in Next 16, so without it the tags decorated a response that was never stored. The opt-in is on this function only; `rawApiFetch` is untouched. - `revalidateContent` takes a mode. `immediate` (the default) calls `updateTag`; `stale-while-revalidate` keeps `revalidateTag(tag, "max")`. Unpublish, delete and a moved slug must not serve the old response even once, so they expire immediately; an edit to a published row that kept its URL is the one case that stays stale-while-revalidate. Public projection - An exposed relation is `{ id }`. The label came from the target's `admin.titleField` - administrative metadata that may name a field the target never publishes, on a row that may itself be a draft. The public service now joins nothing at all, so a target table is never read. Admin labels are unchanged. Correctness - The slug backfill truncates the base before appending the row id. A slug can already fill `varchar(160)`, so `slug || '-' || id` overflowed the column and failed the migration on exactly the rows the statement was rescuing. - `contentPublicFetch` takes `PublicContentTypeDefinition`, so a content type without `publicApi` is a compile error instead of a request to `/api/{pluginId}/content//`. - The detail path is `encodeURIComponent`d. Policy - `publicApi.path` collisions are scoped to `pluginId + path`. The route is `/api/{pluginId}/content/{path}`, so two plugins publishing "articles" do not collide; rejecting them failed an app's boot over a name neither author can see. Inside one plugin it is still an error. - `publicService.findById` is kept as direct-plugin API - an event payload carries a `contentId`, not a slug. It applies the predicate centrally and returns the public projection, and no numeric-id route is generated. - `publishedCondition`'s comment no longer claims the engine generates no public route. Tests - New: `fetch.server.test.ts` (cache mode, tags, path encoding), `revalidate.server.test.ts` (which Next API each mode calls). - `mutation-api.test.ts` drives the real `revalidate.server` against a mocked `next/cache` and asserts the function, not just the tag list. - The Postgres suite seeds duplicate titles, a title filling the whole column and a non-Latin one *between* migrations, so the committed backfill runs against real data, and asserts no relation label reaches a response. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/dev/content-engine/caching.mdx | 96 +++++++++-- .../database-and-migrations.mdx | 15 ++ .../docs/dev/content-engine/limitations.mdx | 17 +- .../docs/dev/content-engine/public-api.mdx | 63 +++++-- .../dev/content-engine/public-service.mdx | 49 +++++- .../docs/dev/content-engine/publication.mdx | 12 +- .../docs/dev/content-engine/schemas.mdx | 4 +- .../docs/dev/content-engine/slug-field.mdx | 40 ++++- .../0024_add_example_article_slug.sql | 22 ++- .../src/content/next/fetch.server.test.ts | 103 +++++++++++ .../vitnode/src/content/next/fetch.server.ts | 41 +++-- packages/vitnode/src/content/next/index.ts | 1 + .../content/next/revalidate.server.test.ts | 90 ++++++++++ .../src/content/next/revalidate.server.ts | 47 ++++- packages/vitnode/src/content/public.test-d.ts | 96 ++++++++++- packages/vitnode/src/content/public.test.ts | 20 ++- packages/vitnode/src/content/registry.test.ts | 23 ++- packages/vitnode/src/content/registry.ts | 21 ++- packages/vitnode/src/content/schemas.ts | 10 +- .../src/content/server/public-routes.test.ts | 17 +- .../src/content/server/public-service.test.ts | 39 ++++- .../src/content/server/public-service.ts | 68 +++----- .../vitnode/src/content/server/publication.ts | 28 +-- .../vitnode/src/content/server/references.ts | 8 +- packages/vitnode/src/content/types.ts | 36 +++- .../content/actions/mutation-api.server.ts | 82 ++++++--- .../content/actions/mutation-api.test.ts | 159 +++++++++++++---- plugins/example/src/database/postgres.test.ts | 162 +++++++++++++++++- plugins/example/src/database/tables.test.ts | 12 +- 29 files changed, 1150 insertions(+), 231 deletions(-) create mode 100644 packages/vitnode/src/content/next/fetch.server.test.ts create mode 100644 packages/vitnode/src/content/next/revalidate.server.test.ts diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx index 7149b5e20..139e621ae 100644 --- a/apps/docs/content/docs/dev/content-engine/caching.mdx +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -43,7 +43,7 @@ same moment the generated ones do. ## Reading -`contentPublicFetch` attaches the right tags for you: +`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"; @@ -63,6 +63,29 @@ const { data } = await contentPublicFetch({ 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 @@ -75,7 +98,7 @@ article must not throw away every article page. not expiring it, - they may be running in `apps/api`, a plain Node process with no Next runtime at all, -- `revalidateTag` needs a Next request scope, which a repository does not own. +- 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. @@ -92,17 +115,17 @@ where invalidation lives. before, is it reachable after, which slugs did it answer to, and which row is it. -| Operation | list | item | old slug | new slug | -| --- | :-: | :-: | :-: | :-: | -| create draft | — | — | — | — | -| update draft | — | — | — | — | -| publish | ✓ | ✓ | — | ✓ | -| update published, same slug | ✓ | ✓ | — | ✓ | -| update published, slug changed | ✓ | ✓ | ✓ | ✓ | -| unpublish | ✓ | ✓ | ✓ | — | -| delete, ever published | ✓ | ✓ | ✓ | — | -| delete, never published | — | — | — | — | -| publish/unpublish no-op | — | — | — | — | +| 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: @@ -115,6 +138,44 @@ Two things worth stating out loud: 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 @@ -153,6 +214,10 @@ export const publishArticle = async (id: number) => { }; ``` +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: @@ -182,5 +247,6 @@ 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` lives behind the Next entrypoint. +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 036f9be75..01ac7217f 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 @@ -241,6 +241,21 @@ 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 usual fix is to append the + row id. Do the truncation **before** the append - a backfilled slug can + already be the full `varchar(160)`, and `slug || '-' || id` on top of that + overflows the column and fails the migration on exactly the rows the statement + was meant to rescue: + +```sql +left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text)) -- then '-' || id +``` + + The suffix is a deterministic, one-off backfill device. Runtime collisions are + a 409 instead; the engine never invents a URL an author did not 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 diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index f0fe5c7b5..2f64c5295 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -22,7 +22,8 @@ other 20%, so you find out here rather than halfway through building. | 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 | -| Deep relation nesting in a public response | One level, `{ id, label }`. Anything more is a custom route | +| 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 | @@ -121,14 +122,22 @@ the slug yourself. See [Slug field](/docs/dev/content-engine/slug-field). 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. Keep your own redirect table if that matters. +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 -`revalidateTag` needs a request scope a repository does not own. +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 diff --git a/apps/docs/content/docs/dev/content-engine/public-api.mdx b/apps/docs/content/docs/dev/content-engine/public-api.mdx index 4c2779a65..bdcb7f3ec 100644 --- a/apps/docs/content/docs/dev/content-engine/public-api.mdx +++ b/apps/docs/content/docs/dev/content-engine/public-api.mdx @@ -111,15 +111,25 @@ construction rather than by three more checks. substring test, so a public route under that name would demand a staff session and never be public at all. -Paths are unique across **every** registered content type, in every plugin. -Routes are plugin-prefixed so two plugins claiming `articles` would not actually -collide at the router - but two content types answering to the same public path -is ambiguous for anyone reading the API, and refusing it keeps the public -surface one flat namespace. The error names both: +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/second -> second.article. Give one of them a different +and @acme/first -> first.post. Give one of them a different `publicApi.path`. ``` @@ -145,16 +155,37 @@ the pagination cursor is read off the row - and then dropped again. ### Relations -An exposed relation comes back as an identifier and the target's own -`admin.titleField`: +An exposed relation comes back as an identifier, and nothing else: ```jsonc -"category": { "id": 3, "label": "News" } +"category": { "id": 3 } ``` -One level, two keys. 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. +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 @@ -198,7 +229,7 @@ still applies. "title": "Hello", "slug": "hello", "excerpt": null, - "category": { "id": 3, "label": "News" }, + "category": { "id": 3 }, "publishedAt": "2026-08-03T10:00:00.000Z" } ], @@ -220,7 +251,11 @@ rejected. ### Detail -Resolved by slug. There is no generated numeric-id detail route. +**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 | | --- | --- | diff --git a/apps/docs/content/docs/dev/content-engine/public-service.mdx b/apps/docs/content/docs/dev/content-engine/public-service.mdx index ac858a0e3..61b264148 100644 --- a/apps/docs/content/docs/dev/content-engine/public-service.mdx +++ b/apps/docs/content/docs/dev/content-engine/public-service.mdx @@ -35,8 +35,32 @@ 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` costs nothing -and is what an event listener holding a `contentId` needs. +`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 @@ -72,7 +96,7 @@ fetched - not fetched and then deleted. const article = await articles.findBySlug("hello-world"); article?.title; // string -article?.category; // { id: number; label: string | null } +article?.category; // { id: number } article?.views; // compile error: not exposed ``` @@ -85,6 +109,16 @@ keys, so a private field is absent at compile time as well as at runtime. 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: @@ -127,6 +161,15 @@ handler: async c => { 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: diff --git a/apps/docs/content/docs/dev/content-engine/publication.mdx b/apps/docs/content/docs/dev/content-engine/publication.mdx index 2ed5c0f80..6050abf2f 100644 --- a/apps/docs/content/docs/dev/content-engine/publication.mdx +++ b/apps/docs/content/docs/dev/content-engine/publication.mdx @@ -213,10 +213,11 @@ the `archived` rows. To actually publish content to the internet, add -[`publicApi`](/docs/dev/content-engine/public-api). That block generates two -read-only routes and a [Public Content Service](/docs/dev/content-engine/public-service), -both of which apply the published predicate themselves - there is no argument a -caller can forget. +[`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: @@ -257,7 +258,8 @@ 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. +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 diff --git a/apps/docs/content/docs/dev/content-engine/schemas.mdx b/apps/docs/content/docs/dev/content-engine/schemas.mdx index 6e5cb148e..d8b65edcd 100644 --- a/apps/docs/content/docs/dev/content-engine/schemas.mdx +++ b/apps/docs/content/docs/dev/content-engine/schemas.mdx @@ -82,7 +82,9 @@ 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, label }`. +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); diff --git a/apps/docs/content/docs/dev/content-engine/slug-field.mdx b/apps/docs/content/docs/dev/content-engine/slug-field.mdx index 27e35b863..0ed34912d 100644 --- a/apps/docs/content/docs/dev/content-engine/slug-field.mdx +++ b/apps/docs/content/docs/dev/content-engine/slug-field.mdx @@ -167,8 +167,19 @@ SET "slug" = NULLIF( -- Two rows can share a title, and a non-Latin title normalises to nothing. -- Both keep their row id as a deterministic tie-breaker; no title is lost. +-- The base is truncated *first*, to leave room for "-" and the id. UPDATE "example_articles" AS a -SET "slug" = concat_ws('-', a."slug", a."id") +SET "slug" = concat_ws( + '-', + NULLIF( + trim( + both '-' from + left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text)) + ), + '' + ), + a."id" +) WHERE a."slug" IS NULL OR EXISTS ( SELECT 1 FROM "example_articles" AS b @@ -179,9 +190,32 @@ ALTER TABLE "example_articles" ALTER COLUMN "slug" SET NOT NULL;--> statement-br CREATE UNIQUE INDEX "example_articles_slug_key" ON "example_articles" USING btree ("slug"); ``` + + The first statement already fills the column: `left("title", 160)` can produce + a slug exactly 160 characters long. Appending `-42` to *that* overflows + `varchar(160)` and fails the migration - on precisely the duplicate rows the + second statement exists to rescue. + +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, unique, and +the same on every machine that runs it. + + + `-42` here is a one-off, deterministic tie-breaker for rows that already + existed. It is **not** what the engine does at runtime: a collision on a live + create or update is a [409](#collisions), never a silently suffixed URL. + + 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, and the Postgres integration test replays it - so the -recipe is checked rather than remembered. +is exactly that file. The Postgres integration test seeds duplicate titles, a +title that fills 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 diff --git a/apps/docs/migrations/0024_add_example_article_slug.sql b/apps/docs/migrations/0024_add_example_article_slug.sql index 9dab665b4..8cf9eb735 100644 --- a/apps/docs/migrations/0024_add_example_article_slug.sql +++ b/apps/docs/migrations/0024_add_example_article_slug.sql @@ -17,10 +17,26 @@ SET "slug" = NULLIF( );--> statement-breakpoint -- Two rows can share a title, and a title in a non-Latin script normalises to -- nothing at all. Both keep their row id as a deterministic tie-breaker - no --- title is overwritten and no row is dropped. `concat_ws` skips the NULL, so a --- row with no usable title becomes just its id. +-- title is overwritten and no row is dropped. +-- +-- 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) and fail +-- the migration on precisely the rows this statement exists to rescue. The +-- second trim runs after truncation, so cutting mid-word cannot leave a +-- trailing dash. `NULLIF` + `concat_ws` then drop an empty base entirely, so a +-- row with no usable title becomes just its id rather than "-12". UPDATE "example_articles" AS a -SET "slug" = concat_ws('-', a."slug", a."id") +SET "slug" = concat_ws( + '-', + NULLIF( + trim( + both '-' from + left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text)) + ), + '' + ), + a."id" +) WHERE a."slug" IS NULL OR EXISTS ( SELECT 1 FROM "example_articles" AS b 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 index 0164450a8..ac437025d 100644 --- a/packages/vitnode/src/content/next/fetch.server.ts +++ b/packages/vitnode/src/content/next/fetch.server.ts @@ -1,7 +1,10 @@ import "server-only"; import type { z } from "zod"; -import type { AnyContentTypeDefinition } from "../types"; +import type { + AnyContentTypeDefinition, + PublicContentTypeDefinition, +} from "../types"; import { rawApiFetch } from "../../lib/fetcher/raw"; import { @@ -16,14 +19,24 @@ export interface ContentPublicFetchResult { } /** - * Reads the generated public API from a server component, tagged for you. + * Reads the generated public API from a server component, cached and tagged. * - * The tags are the same strings {@link contentPublicListTag} and friends - * produce, so `revalidateContent` expires exactly these responses - and an app - * that tags its own fetches with them gets invalidated at the same moment. + * 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, @@ -32,7 +45,7 @@ export const contentPublicFetch = async ({ schema, slug, }: { - definition: AnyContentTypeDefinition; + definition: PublicContentTypeDefinition; pluginId: string; query?: Record; schema?: TSchema; @@ -48,10 +61,18 @@ export const contentPublicFetch = async ({ const response = await rawApiFetch({ method: "get", module: `content/${definition.publicApi.path}`, - // Next augments the global `RequestInit` with `next`, which is why this - // passes straight through the shared fetcher's `options`. - options: { next: { tags } }, - path: slug === undefined ? "/" : `/${slug}`, + 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, }); diff --git a/packages/vitnode/src/content/next/index.ts b/packages/vitnode/src/content/next/index.ts index a4aabfb0b..f01f56e7e 100644 --- a/packages/vitnode/src/content/next/index.ts +++ b/packages/vitnode/src/content/next/index.ts @@ -11,3 +11,4 @@ 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 index 8b0fc43ad..9ac72f961 100644 --- a/packages/vitnode/src/content/next/revalidate.server.ts +++ b/packages/vitnode/src/content/next/revalidate.server.ts @@ -1,10 +1,22 @@ import "server-only"; -import { revalidateTag } from "next/cache"; +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. * @@ -15,15 +27,34 @@ import { contentInvalidationTags } from "../cache"; * * 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 - * `revalidateTag` to attach to. A direct caller invalidates for itself, after - * it commits. + * 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): void => { +export const revalidateContent = ( + input: ContentInvalidationInput, + options?: { mode?: ContentInvalidationMode }, +): void => { + const mode = options?.mode ?? "immediate"; + for (const tag of contentInvalidationTags(input)) { - // The two-argument form: a profile is required for stale-while-revalidate, - // and `max` is right here because a tag is only expired when the underlying - // row actually changed. + 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 index bdd6574cc..68d6c04a7 100644 --- a/packages/vitnode/src/content/public.test-d.ts +++ b/packages/vitnode/src/content/public.test-d.ts @@ -1,12 +1,13 @@ import { assertType, describe, expectTypeOf, it } from "vitest"; -import type { - testArticleContentType, +import type { testArticleContentType } from "@/tests/content-fixtures"; + +import { testCategoryContentType, + testPostContentType, } from "@/tests/content-fixtures"; -import { testPostContentType } from "@/tests/content-fixtures"; - +import type { ContentPublicService } from "./server/public-service"; import type { AnyContentTypeDefinition, ContentPublicFieldName, @@ -14,10 +15,12 @@ import type { 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; @@ -95,16 +98,19 @@ describe("publicApi types", () => { >().toEqualTypeOf(); }); - it("projects a relation down to an id and a label", () => { + 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; - label: null | string; - }>(); + 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", () => { @@ -144,4 +150,76 @@ describe("publicApi types", () => { }); }); }); + + 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 index 8dc3be340..3fd1ff72b 100644 --- a/packages/vitnode/src/content/public.test.ts +++ b/packages/vitnode/src/content/public.test.ts @@ -287,11 +287,11 @@ describe("publicApi", () => { expect(keys).not.toContain("id"); }); - it("projects a relation as an id and a label", () => { + 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, label: "News" }, + category: { id: 3 }, excerpt: null, publishedAt: new Date(), slug: "hello", @@ -299,12 +299,26 @@ describe("publicApi", () => { }; expect(schemas.publicSelectObject.safeParse(row).success).toBe(true); - // Not the related row - one level, two keys, no population. + // 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"]); }); diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index e6dad7e13..541df4c8f 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -348,7 +348,7 @@ describe("public paths", () => { ).not.toThrow(); }); - it("rejects two content types claiming the same path", () => { + it("rejects two content types in one plugin claiming the same path", () => { expect(() => validateContentTypes([ entry(publicWidget("test.one", "test_ones", "things")), @@ -357,15 +357,30 @@ describe("public paths", () => { ).toThrow(/Public path "things" is claimed by both/); }); - it("names both plugins and both content types", () => { + 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/second"), + entry(publicWidget("test.two", "test_twos", "things"), "@acme/first"), ]), ).toThrow( - /@acme\/first -> test\.one.*@acme\/second -> test\.two|@acme\/second -> test\.two.*@acme\/first -> test\.one/, + /@acme\/first -> test\.one.*@acme\/first -> test\.two|@acme\/first -> test\.two.*@acme\/first -> test\.one/, ); }); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index 6ed2cf83c..a6ea9bfe7 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -39,8 +39,9 @@ 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, two content types resolving to the same Postgres index name, or - * two of them claiming the same public path. + * 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[], @@ -86,21 +87,23 @@ export const validateContentTypes = ( assertFilterKeys(definition); - // Public paths are checked across *every* plugin, not per plugin. Routes - // are mounted under `/api/{pluginId}/...`, so two plugins claiming - // "articles" would not actually collide at the router - but two content - // types answering to the same public path is ambiguous for anyone reading - // the API, and refusing it keeps the public surface one flat namespace. + // 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 duplicatePath = byPublicPath.get(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(path, entry); + byPublicPath.set(pathKey, entry); } // `resolveContentIndexes` already rejects a collision inside one content diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 92a238939..384613b05 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -242,9 +242,15 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => }), ); -/** An exposed relation comes back as an identifier and a display label. */ +/** + * 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(), label: z.string().nullable() }); + z.object({ id: z.number() }); /** * The public response shape, built from the allowlist and nothing else. diff --git a/packages/vitnode/src/content/server/public-routes.test.ts b/packages/vitnode/src/content/server/public-routes.test.ts index 53b937ca1..9372f20de 100644 --- a/packages/vitnode/src/content/server/public-routes.test.ts +++ b/packages/vitnode/src/content/server/public-routes.test.ts @@ -22,7 +22,7 @@ const posts = createContentModel(testPostContentType, { const PLUGIN_ID = "@vitnode/example"; const publicRow = { - category: { id: 3, label: "News" }, + category: { id: 3 }, excerpt: "Prose", publishedAt: new Date("2026-08-01T09:00:00.000Z"), slug: "hello-world", @@ -193,7 +193,7 @@ describe("public detail route", () => { expect(body).not.toHaveProperty("id"); }); - it("projects the relation as an id and a label", async () => { + it("projects the relation as an identifier and nothing else", async () => { const { app, service } = harness(); service.findBySlug.mockResolvedValue(publicRow); @@ -201,7 +201,18 @@ describe("public detail route", () => { category: unknown; }; - expect(body.category).toEqual({ id: 3, label: "News" }); + 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 () => { diff --git a/packages/vitnode/src/content/server/public-service.test.ts b/packages/vitnode/src/content/server/public-service.test.ts index 03e2dc5bf..0eec81d8c 100644 --- a/packages/vitnode/src/content/server/public-service.test.ts +++ b/packages/vitnode/src/content/server/public-service.test.ts @@ -90,7 +90,6 @@ const storedRow = { category: 3, excerpt: "Prose", id: 12, - label__category: "News", publishedAt: new Date("2026-08-01T09:00:00.000Z"), slug: "hello-world", title: "Hello world", @@ -188,7 +187,6 @@ describe("projection", () => { "category", "excerpt", "id", - "label__category", "publishedAt", "slug", "title", @@ -231,21 +229,48 @@ describe("projection", () => { expect(await service.findBySlug("hello-world")).not.toHaveProperty("id"); }); - it("projects a relation to an id and a label", async () => { + it("projects a relation down to an identifier", async () => { const { service } = publicService([[storedRow]]); expect(await service.findBySlug("hello-world")).toMatchObject({ - category: { id: 3, label: "News" }, + category: { id: 3 }, }); }); - it("joins once per exposed relation, and not for anything else", async () => { + 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"); - // `author` is a user field and is not exposed, so it costs no join. - expect(opsOf(calls, "leftJoin")).toHaveLength(1); + // 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 () => { diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index b5cad9273..ca7f602e9 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -30,7 +30,6 @@ import { buildOrderColumn, buildSearchCondition, } from "./query"; -import { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; export interface ContentPublicFindManyArgs { /** Equality filters, restricted to `publicApi.filterableFields`. */ @@ -85,6 +84,11 @@ const clampPageSize = (value: string | undefined): string | undefined => { * 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, @@ -117,13 +121,11 @@ export const createContentPublicService = < const primaryCursor = columns.id as PgColumn< ColumnBaseConfig<"number", string> >; - const references = resolveReferenceTargets(definition, table, columns); const exposed = publicApi.fields; const exposesId = exposed.includes("id"); - // Only the relations the allowlist names: a `user` field is never exposable, - // and an unexposed relation should not cost a join either. - const exposedRelations = exposed.filter( - name => fields[name]?.kind === "relation" && references[name], + // 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); @@ -132,18 +134,14 @@ export const createContentPublicService = < const selection = (): Record => ({ id: primaryCursor, ...Object.fromEntries(exposed.map(name => [name, columns[name]])), - ...Object.fromEntries( - exposedRelations.map(name => [ - `${LABEL_PREFIX}${name}`, - references[name].labelColumn, - ]), - ), }); /** * Turns one raw row into the public projection: relations collapse to - * `{ id, label }`, the label columns disappear, and `id` goes with them - * unless it was asked for. + * `{ 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, @@ -151,16 +149,13 @@ export const createContentPublicService = < const projected: Record = {}; for (const name of exposed) { - if (!exposedRelations.includes(name)) { + if (!exposedRelations.has(name)) { projected[name] = row[name]; continue; } const id = row[name]; - projected[name] = - typeof id === "number" - ? { id, label: toLabel(row[`${LABEL_PREFIX}${name}`]) } - : null; + projected[name] = typeof id === "number" ? { id } : null; } if (exposesId) projected.id = row.id; @@ -171,17 +166,10 @@ export const createContentPublicService = < const readOne = async ( condition: SQL, ): Promise | null> => { - let builder = c.get("db").select(selection()).from(table).$dynamic(); - - for (const name of exposedRelations) { - const target = references[name]; - builder = builder.leftJoin( - target.aliased, - eq(target.owner, target.idColumn), - ); - } - - const [row] = await builder + const [row] = await c + .get("db") + .select(selection()) + .from(table) .where(and(publishedCondition(published), condition)) .limit(1); @@ -234,26 +222,18 @@ export const createContentPublicService = < }, table, where: conditions.length > 1 ? and(...conditions) : conditions[0], - query: async ({ limit, orderBy: order, where }) => { - let builder = c.get("db").select(selection()).from(table).$dynamic(); - - for (const name of exposedRelations) { - const target = references[name]; - builder = builder.leftJoin( - target.aliased, - eq(target.owner, target.idColumn), - ); - } - - return await builder + 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.ts b/packages/vitnode/src/content/server/publication.ts index f7e8bc519..1c8471d45 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -55,30 +55,36 @@ export const publicationColumns = ( * status = 'published' AND published_at IS NOT NULL AND published_at <= NOW() * ``` * - * Nothing in the engine generates a public route yet, so today this exists for - * **hand-written plugin queries** - the supported way to expose published - * content while the public read layer is still being built: + * 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(articles.columns)); + * .where( + * publishedCondition(publicationColumns(articleContentType, articles.columns)), + * ); * ``` * - * It is exported rather than kept internal because writing that predicate by - * hand is exactly the thing worth getting wrong once: forget the `IS NOT NULL` - * and a row whose timestamp was cleared leaks. The generated public service in - * a later PR will consume this same helper, so the invariant has one definition - * either way. + * 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` does not expose anything publicly on its own. It adds - * the lifecycle; serving it is still your route. + * 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, diff --git a/packages/vitnode/src/content/server/references.ts b/packages/vitnode/src/content/server/references.ts index 887ce998a..dd00ce03c 100644 --- a/packages/vitnode/src/content/server/references.ts +++ b/packages/vitnode/src/content/server/references.ts @@ -45,9 +45,11 @@ export const toLabel = (value: unknown): null | string => { * so the engine needs no separate table registry - and because the FK thunk is * evaluated here, circular content type references stay safe. * - * Shared by the admin service (which joins every reference for its `labels` - * object) and the public one (which joins only the exposed relations), so both - * resolve a label exactly the same way. + * **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, diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 789121bda..2f2d4c236 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -488,6 +488,23 @@ export interface ResolvedContentPublicApiConfig< // 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, @@ -627,16 +644,23 @@ export type ContentReferenceFieldName = FieldNamesOfKind< // --------------------------------------------------------------------------- /** - * How an exposed `relation` comes back: an identifier and the target's own - * `admin.titleField`, and nothing else. + * 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. * - * Deliberately not the related row. Deep nesting and arbitrary population are - * out of scope - they are the point at which a REST projection turns into - * GraphQL, and a hand-written route is the better answer. + * 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; - label: null | string; } /** The exposed field names of one content type, read off its resolved config. */ 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 04768a5df..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,6 +3,7 @@ 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"; @@ -92,6 +93,27 @@ const readRow = async ( 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, @@ -104,14 +126,17 @@ const invalidate = ( 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, - }); + 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 ( @@ -193,13 +218,18 @@ export const deleteContentAction = async ( // 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, - }); + 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 {}; @@ -238,14 +268,20 @@ const publicationAction = async ( 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, - }); + 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 {}; 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 index 161a05651..3b682de32 100644 --- 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 @@ -8,26 +8,30 @@ import { testPostContentType, } from "@/tests/content-fixtures"; -const revalidated: string[][] = []; +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; -vi.mock("next/cache", () => ({ revalidatePath: () => undefined })); - -vi.mock("@/content/next/revalidate.server", async () => { - // The decision table itself is pure and tested in `content/cache.test.ts`; - // what matters here is the input the server action hands it. - const { contentInvalidationTags } = await import("@/content/cache"); +// 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", () => ({})); - return { - revalidateContent: ( - input: Parameters[0], - ) => { - revalidated.push(contentInvalidationTags(input)); - }, - }; -}); +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: () => ({ @@ -53,6 +57,7 @@ vi.mock("@/content/admin/fetch.server", () => ({ })); const { + createContentAction, deleteContentAction, editContentAction, publishContentAction, @@ -65,8 +70,12 @@ 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(() => { - revalidated.length = 0; + cacheCalls.length = 0; fetches.length = 0; responses = []; definition = testPostContentType; @@ -82,12 +91,20 @@ describe("edit", () => { await editContentAction("test.post", 7, { title: "Hello" }); expect(fetches.map(item => item.method)).toEqual(["get", "put"]); - expect(revalidated[0]).toEqual([ - LIST, - ITEM, - slugTag("old"), - slugTag("new"), - ]); + 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 () => { @@ -98,7 +115,20 @@ describe("edit", () => { await editContentAction("test.post", 7, { title: "Hello" }); - expect(revalidated[0]).toEqual([LIST, ITEM, slugTag("same")]); + 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 () => { @@ -109,7 +139,7 @@ describe("edit", () => { await editContentAction("test.post", 7, { title: "Hello" }); - expect(revalidated[0]).toEqual([]); + expect(cacheCalls).toEqual([]); }); it("does not read anything extra for a content type with no public API", async () => { @@ -121,7 +151,7 @@ describe("edit", () => { await editContentAction("test.category", 7, { title: "Hello" }); expect(fetches.map(item => item.method)).toEqual(["put"]); - expect(revalidated).toEqual([]); + expect(cacheCalls).toEqual([]); }); }); @@ -139,7 +169,24 @@ describe("publish and unpublish", () => { await publishContentAction("test.post", 7); - expect(revalidated[0]).toEqual([LIST, ITEM, slugTag("hello")]); + 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 () => { @@ -155,7 +202,25 @@ describe("publish and unpublish", () => { await unpublishContentAction("test.post", 7); - expect(revalidated[0]).toEqual([LIST, ITEM, slugTag("hello")]); + 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 () => { @@ -173,7 +238,7 @@ describe("publish and unpublish", () => { await publishContentAction("test.post", 7); - expect(revalidated).toEqual([]); + expect(cacheCalls).toEqual([]); }); }); @@ -188,7 +253,22 @@ describe("delete", () => { await deleteContentAction("test.post", 7); - expect(revalidated[0]).toEqual([LIST, ITEM, slugTag("hello")]); + 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 () => { @@ -203,7 +283,7 @@ describe("delete", () => { await deleteContentAction("test.post", 7); - expect(revalidated[0]).toEqual([LIST, ITEM, slugTag("hello")]); + expect(tags()).toEqual([LIST, ITEM, slugTag("hello")]); }); it("expires nothing for a row that never went live", async () => { @@ -216,7 +296,22 @@ describe("delete", () => { await deleteContentAction("test.post", 7); - expect(revalidated[0]).toEqual([]); + 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([]); }); }); @@ -233,6 +328,6 @@ describe("failures", () => { const result = await editContentAction("test.post", 7, { slug: "taken" }); expect(result.status).toBe(409); - expect(revalidated).toEqual([]); + expect(cacheCalls).toEqual([]); }); }); diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index 0a2c21dc8..cf3274e47 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -38,9 +38,55 @@ const databaseName = (() => { const here = dirname(fileURLToPath(import.meta.url)); /** The committed migrations - the exact DDL a fresh database would run. */ -const migrationSql = EXAMPLE_MIGRATIONS.map(file => - readFileSync(resolve(here, "../../../../apps/docs/migrations", file), "utf8"), -).join("\n--> statement-breakpoint\n"); +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 case that used to overflow: the base was + * already 160 characters and the duplicate pass appended `-` on top. + */ +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. + */ +const BACKFILL_ROWS = [ + { code: "mig-1", title: "Same Title" }, + { code: "mig-2", title: "Same Title" }, + { code: "mig-3", title: LONG_TITLE }, + { code: "mig-4", title: LONG_TITLE }, + { code: "mig-5", title: "日本語のタイトル" }, + { code: "mig-6", 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. @@ -97,11 +143,40 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { `); 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" ("category", "code", "title") + VALUES (${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"`; + context = { get: (key: string) => key === "db" ? drizzle(sql, { casing: "camelCase" }) : undefined, @@ -112,6 +187,69 @@ 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 was already 160 characters + // before the duplicate pass appended their id. + expect( + Math.max(...backfilled.map(row => row.slug.length)), + ).toBeLessThanOrEqual(160); + }); + + it("made every slug unique", () => { + const slugs = backfilled.map(row => row.slug); + + expect(new Set(slugs).size).toBe(slugs.length); + }); + + it("separates two rows with the same ordinary title", () => { + const first = backfilledBy("mig-1"); + const second = backfilledBy("mig-2"); + + expect(first.slug).toBe(`same-title-${first.id}`); + expect(second.slug).toBe(`same-title-${second.id}`); + }); + + it("separates two rows whose title fills the whole column", () => { + const first = backfilledBy("mig-3"); + const second = backfilledBy("mig-4"); + + // 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, and the row keeps its title. + const row = backfilledBy("mig-5"); + + expect(row.slug).toBe(String(row.id)); + }); + + it("leaves an unambiguous title alone", () => { + expect(backfilledBy("mig-6").slug).toBe("only-one-of-these"); + }); + + it("never leaves a leading or trailing dash", () => { + expect(backfilled.every(row => !/^-|-$/.test(row.slug))).toBe(true); + }); + }); + it("applies the unique index the descriptor asked for", async () => { const indexes = await sql` SELECT indexdef FROM pg_indexes @@ -428,7 +566,9 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { const [user] = await sql<{ id: number }[]>` INSERT INTO "core_users" ("name") VALUES ('Ada') RETURNING "id" `; - const category = await categories.create({ name: "Public" }); + // 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, @@ -466,13 +606,19 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { "title", ]); expect(detail).toMatchObject({ - category: { id: category.id, label: "Public" }, + 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 } }), diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index 4911e08c6..78cbb5e55 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -205,7 +205,17 @@ describe("the generated migration", () => { it("disambiguates with the row id rather than a placeholder", () => { // No "untitled-1" and no random suffix: every row keeps whatever its // title gave it, and only the collisions gain the id. - expect(migration).toContain(`concat_ws('-', a."slug", a."id")`); + expect(migration).toContain(`a."id"`); + expect(migration).not.toMatch(/random\(|gen_random_uuid\(/); + }); + + 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 and fail the migration on exactly + // the rows the duplicate pass exists to rescue. + expect(migration).toContain( + `left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text))`, + ); }); }); From d33630cd092b16545dfe55c9ecf60abc266a076b Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 4 Aug 2026 12:36:36 +0200 Subject: [PATCH 13/13] fix: Suffix every backfilled slug, not just the ambiguous ones The duplicate pass rescued a row by appending its id - but the value it built is itself a slug, so it could land on a natural one that was left alone: id=1 "Foo 2" -> "foo-2" unique, untouched id=2 "Foo" -> "foo" -> "foo-2" rescued onto row 1 id=3 "Foo" -> "foo" -> "foo-3" The id fallback had the same hole: a title that normalises to nothing becomes its bare id, which collides with a row whose title genuinely normalised to that number. Neither surfaced until `CREATE UNIQUE INDEX` failed two statements later, halfway through a deploy. Dropping the `WHERE` and suffixing every row removes the class of bug rather than the instance. Every value is `-`, or `` when the title left nothing behind, so two can only be equal if their ids are - and ids are the primary key. No loop, no second pass, nothing random. The cost is that unambiguous rows are suffixed too. That is migration-only: runtime create and update still return 409 on a collision and never rewrite a URL an author chose. The Postgres suite seeds both collision scenarios with explicit ids - they only reproduce at particular ids, so the sequence must not pick them - alongside the existing long-title and non-Latin cases, and replays the committed migration over them. Restoring the old predicate fails that suite on the unique index. Co-Authored-By: Claude Opus 5 (1M context) --- .../database-and-migrations.mdx | 23 ++-- .../docs/dev/content-engine/slug-field.mdx | 71 +++++++---- .../0024_add_example_article_slug.sql | 37 +++--- plugins/example/src/database/postgres.test.ts | 114 +++++++++++++----- plugins/example/src/database/tables.test.ts | 15 ++- 5 files changed, 184 insertions(+), 76 deletions(-) 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 01ac7217f..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 @@ -241,19 +241,26 @@ 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 usual fix is to append the - row id. Do the truncation **before** the append - a backfilled slug can - already be the full `varchar(160)`, and `slug || '-' || id` on top of that - overflows the column and fails the migration on exactly the rows the statement - was meant to rescue: + + 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. Runtime collisions are - a 409 instead; the engine never invents a URL an author did not choose. + 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 diff --git a/apps/docs/content/docs/dev/content-engine/slug-field.mdx b/apps/docs/content/docs/dev/content-engine/slug-field.mdx index 0ed34912d..802415ca3 100644 --- a/apps/docs/content/docs/dev/content-engine/slug-field.mdx +++ b/apps/docs/content/docs/dev/content-engine/slug-field.mdx @@ -165,9 +165,8 @@ SET "slug" = NULLIF( '' );--> statement-breakpoint --- Two rows can share a title, and a non-Latin title normalises to nothing. --- Both keep their row id as a deterministic tie-breaker; no title is lost. --- The base is truncated *first*, to leave room for "-" and the id. +-- 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( '-', @@ -179,22 +178,45 @@ SET "slug" = concat_ws( '' ), a."id" -) -WHERE a."slug" IS NULL - OR EXISTS ( - SELECT 1 FROM "example_articles" AS b - WHERE b."slug" = a."slug" AND b."id" <> a."id" - );--> statement-breakpoint +);--> 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 already fills the column: `left("title", 160)` can produce - a slug exactly 160 characters long. Appending `-42` to *that* overflows - `varchar(160)` and fails the migration - on precisely the duplicate rows the - second statement exists to rescue. + 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 @@ -203,19 +225,26 @@ script becomes just its id rather than `-42`. -Every migrated slug is therefore non-empty, at most 160 characters, unique, and -the same on every machine that runs it. +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. - `-42` here is a one-off, deterministic tie-breaker for rows that already - existed. It is **not** what the engine does at runtime: a collision on a live - create or update is a [409](#collisions), never a silently suffixed URL. + 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 duplicate titles, a -title that fills the whole column and a non-Latin one, then replays the -migration over them - so the recipe is checked rather than remembered. +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 diff --git a/apps/docs/migrations/0024_add_example_article_slug.sql b/apps/docs/migrations/0024_add_example_article_slug.sql index 8cf9eb735..c20f98f7f 100644 --- a/apps/docs/migrations/0024_add_example_article_slug.sql +++ b/apps/docs/migrations/0024_add_example_article_slug.sql @@ -15,16 +15,30 @@ SET "slug" = NULLIF( trim(both '-' from regexp_replace(lower(left("title", 160)), '[^a-z0-9]+', '-', 'g')), '' );--> statement-breakpoint --- Two rows can share a title, and a title in a non-Latin script normalises to --- nothing at all. Both keep their row id as a deterministic tie-breaker - no --- title is overwritten and no row is dropped. +-- 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) and fail --- the migration on precisely the rows this statement exists to rescue. The --- second trim runs after truncation, so cutting mid-word cannot leave a --- trailing dash. `NULLIF` + `concat_ws` then drop an empty base entirely, so a --- row with no usable title becomes just its id rather than "-12". +-- 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( '-', @@ -36,11 +50,6 @@ SET "slug" = concat_ws( '' ), a."id" -) -WHERE a."slug" IS NULL - OR EXISTS ( - SELECT 1 FROM "example_articles" AS b - WHERE b."slug" = a."slug" AND b."id" <> a."id" - );--> statement-breakpoint +);--> 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/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index cf3274e47..b81e566bb 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -53,8 +53,8 @@ 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 case that used to overflow: the base was - * already 160 characters and the duplicate pass appended `-` on top. + * 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); @@ -62,14 +62,31 @@ 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 = [ - { code: "mig-1", title: "Same Title" }, - { code: "mig-2", title: "Same Title" }, - { code: "mig-3", title: LONG_TITLE }, - { code: "mig-4", title: LONG_TITLE }, - { code: "mig-5", title: "日本語のタイトル" }, - { code: "mig-6", title: "Only One Of These" }, + // 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 { @@ -162,8 +179,8 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { `; for (const row of BACKFILL_ROWS) { await sql` - INSERT INTO "example_articles" ("category", "code", "title") - VALUES (${seedCategory.id}, ${row.code}, ${row.title}) + INSERT INTO "example_articles" ("id", "category", "code", "title") + VALUES (${row.id}, ${seedCategory.id}, ${row.code}, ${row.title}) `; } @@ -176,6 +193,14 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { // 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) => @@ -196,11 +221,9 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { 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 was already 160 characters - // before the duplicate pass appended their id. - expect( - Math.max(...backfilled.map(row => row.slug.length)), - ).toBeLessThanOrEqual(160); + // 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", () => { @@ -209,17 +232,49 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { 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", () => { - const first = backfilledBy("mig-1"); - const second = backfilledBy("mig-2"); + 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, + ); + }); - expect(first.slug).toBe(`same-title-${first.id}`); - expect(second.slug).toBe(`same-title-${second.id}`); + 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-3"); - const second = backfilledBy("mig-4"); + 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 @@ -235,18 +290,19 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { 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, and the row keeps its title. - const row = backfilledBy("mig-5"); + // 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("leaves an unambiguous title alone", () => { - expect(backfilledBy("mig-6").slug).toBe("only-one-of-these"); - }); - - it("never leaves a leading or trailing dash", () => { - expect(backfilled.every(row => !/^-|-$/.test(row.slug))).toBe(true); + 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"); }); }); diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index 78cbb5e55..d0b2aab0f 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -203,16 +203,23 @@ describe("the generated migration", () => { }); it("disambiguates with the row id rather than a placeholder", () => { - // No "untitled-1" and no random suffix: every row keeps whatever its - // title gave it, and only the collisions gain the id. + // 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 and fail the migration on exactly - // the rows the duplicate pass exists to rescue. + // whole thing would overflow the column. expect(migration).toContain( `left(coalesce(a."slug", ''), 160 - 1 - length(a."id"::text))`, );