diff --git a/apps/api/.env.example b/apps/api/.env.example index e3ec0f8ee..af3fdeccf 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,6 +9,18 @@ NEXT_PUBLIC_API_URL=http://localhost:8080 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key +# === Content Preview Secret === +# Signs the preview links that let a reviewer read an unpublished record +# without an account. The signature is the *only* access control on those +# links, so this is required whenever a content type has `editorial.preview` +# enabled: at least 32 random bytes, or the API refuses to boot in production +# and preview stays switched off everywhere else. +# +# openssl rand -base64 32 +# +# Rotating this value revokes every outstanding preview link at once. +CONTENT_PREVIEW_SECRET= + # === AI (Vercel AI SDK) === # Gateway (default): one key for Anthropic, OpenAI, Google, etc. via `provider/model` # id strings in `buildApiConfig({ ai: { models } })`. diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 3cebdeb87..726860ed7 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -7,6 +7,18 @@ NEXT_PUBLIC_WEB_URL=http://localhost:3000 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key +# === Content Preview Secret === +# Signs the preview links that let a reviewer read an unpublished record +# without an account. The signature is the *only* access control on those +# links, so this is required whenever a content type has `editorial.preview` +# enabled: at least 32 random bytes, or the API refuses to boot in production +# and preview stays switched off everywhere else. +# +# openssl rand -base64 32 +# +# Rotating this value revokes every outstanding preview link at once. +CONTENT_PREVIEW_SECRET= + # === Docker Database Postgres === POSTGRES_USER=root POSTGRES_PASSWORD=root diff --git a/apps/docs/content/docs/dev/advanced/queue.mdx b/apps/docs/content/docs/dev/advanced/queue.mdx index 6ac581513..d5eb9fcdc 100644 --- a/apps/docs/content/docs/dev/advanced/queue.mdx +++ b/apps/docs/content/docs/dev/advanced/queue.mdx @@ -145,9 +145,86 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; type: "Date", default: "now", }, + pluginId: { + description: + "Who owns the handler, when that is not the plugin handling the request. The worker resolves handlers by `${pluginId}:${name}`, so dispatching a core task from a plugin route needs this - otherwise nothing ever claims the row.", + type: "string", + default: "the requesting plugin, or @vitnode/core", + }, + tx: { + description: + "Join an existing transaction instead of using the request handle. Needed whenever the row the task refers to is written in the same unit of work, or the queue row can commit while that row rolls back.", + type: "Transaction", + }, }} /> + + The [Content Engine](/docs/dev/content-engine/scheduling) books a schedule row + and its queue task in one transaction, from a plugin's route, against a task + core registers. That needs `tx` for atomicity and `pluginId` so the worker can + find the handler - and it is a shape any plugin can reuse. + + +## Tasks core ships + +| Task | Attempts | What it does | +| --- | --- | --- | +| `send-email` | 3 | Delivers a rendered email through the configured provider | +| `rebuild-search-index` | 3 | Clears and rebuilds the [search](/docs/dev/content-engine/search) index, whole or per collection | +| `content-schedule` | 3 | Runs one [scheduled publication](/docs/dev/content-engine/scheduling). A cancelled, rescheduled or already-executed schedule is a no-op | +| `content-schedule-effects` | 5 | Emits the event, syncs search and expires the cache for a scheduled transition that has **already committed**. Never republishes | + +`content-schedule` is worth reading as a pattern: its payload is +`{ scheduleId, generation }` and nothing else. Every real value is re-read from +the row under `FOR UPDATE`, so a task left over from a plan that has since +changed finds a mismatch and quietly does nothing - which is far more reliable +than trying to delete a queued row. It holds that lock from the claim all the +way to the commit, so a cancel arriving mid-flight waits and then honestly +reports that the schedule already ran. + +### Why the effects are a second task + +The pair is worth reading as a pattern too, because splitting them is the whole +point: + +```text +content-schedule claim → publish → revision → settle → enqueue effects + ── one transaction ────────────────────────────────── +content-schedule-effects event → search → cache bridge +``` + +A transition is a database write that either committed or did not. Announcing it +is three calls to systems a transaction cannot reach. Retrying them **together** +would re-run a publish that is idempotent - so the second run finds nothing +changed and skips the announcements entirely, which is how a scheduled unpublish +ends up permanently serving a page it should have expired. + +The effects row is written inside the transition's transaction, so it exists if +and only if the transition committed, and it carries everything frozen rather +than re-reading a record that may have moved on. + +All three have to land for the task to succeed - a listener that threw, a search +engine that refused the write, or any configured web origin that did not accept +its invalidation each fail the run, and the reasons are combined into one +`effectsError`. `EventsModel.emit()` never throws, so the task reads +`EventEmitResult.failures` rather than waiting for an exception that is not +coming. + +Delivery is at-least-once: the search write and the cache expiry are idempotent, +but a listener can see one `published` twice, so a listener that must act once +keys off the `scheduleId` in the payload. + +The event's envelope is stamped with the plugin that owns the **content type**, +not with core. Core owns the handler; `content.example.article.published` +belongs to the example plugin however it was triggered. + + + The schedule stays `completed` and the reason lands in `effectsError` on the + schedule row. Nothing is ever moved back to `pending` because an event + bounced - the record really did publish. + + ## Retries and backoff If a handler throws, the task is retried with an exponential backoff diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index 2d1b287d1..f006e0ab7 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -31,6 +31,7 @@ You get a nav item, a breadcrumb, and a screen at: - **Pagination** - the standard cursor pagination, capped at 100 per page - **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open - **Delete** - a confirmation dialog +- **History** - with [`editorial`](#editorial): every version, a diff, and restore - **Empty, loading and error states** - out of the box ## What "lazy-loaded on open" actually means @@ -67,14 +68,14 @@ because the frontend forgot something the backend allows: "createdAt", "updatedAt", ...(definition.publication.enabled ? ["status", "publishedAt"] : []), + ...(definition.editorial.enabled ? ["version"] : []), ]; ``` -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. +System columns need no entry in `orderableFields`, and neither do `status`, +`publishedAt` or `version` - but those three appear **only** when their block 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. @@ -95,6 +96,23 @@ wrong - a delete blocked by a foreign key does not read like a crashed server: | 409 | this record is still referenced by other content | | anything else | the generic server error | +An [editorial](/docs/dev/content-engine/editorial) content type answers those +two ambiguous statuses with a JSON `code`, so the wording follows what actually +happened rather than the number: + +| Code | The person is told | +| --- | --- | +| `CONTENT_VERSION_CONFLICT` | someone else saved this while you were editing - your changes are still here | +| `CONTENT_UNIQUE_CONFLICT` | a record with these values already exists | +| `CONTENT_REVISION_NOT_RESTORABLE` | this version cannot be restored: *fields* no longer fit this content type | + +A **delete** that hits `CONTENT_VERSION_CONFLICT` gets its own wording, because +the situation is different: nothing of yours is at stake, the record simply +moved. It reads *"someone saved it after this page loaded, so it was not +deleted - refresh and check what changed"*, and it deliberately does **not** +retry with the new version. A confirmation dialog cannot ask about a change +nobody has seen. + The generated routes translate Postgres error codes into a status and a generic sentence. Constraint names, column names and values stay on the server; the @@ -144,6 +162,128 @@ 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. +## Editorial + +A content type with [`editorial`](/docs/dev/content-engine/editorial) gains a +**clock** row action, an **eye** one when preview is on, and changes how the +edit dialog handles a failed save. The cell reads left to right: + +```text +Preview · Schedule · History · Publish/Unpublish · Edit · Delete +``` + +### History + +The dialog body is lazy-loaded exactly like the form, and for the same reason. +It lists one line per version - the operation as a badge, the author or +**System**, a localised date, and which fields moved - with a **Current** badge +on the newest. + +Twenty-five at a time, with **Load older versions** underneath when there are +more. It appends rather than replaces, so scrolling back through a long history +never loses what you already read, and the button disappears once the last page +arrives. Retention defaults to 50 and a page to 25, so this is the ordinary case +rather than an edge one. + +Restoring reloads the list in place - the restore writes a revision of its own, +and it should appear where it happened - refreshes the table behind the dialog, +and adopts the new current version, so a second restore in the same sitting does +not conflict with the first. + +The list carries metadata only. Expanding a version fetches that one snapshot +and renders a field-level diff against the version before it: + +| Kind | Rendered as | +| --- | --- | +| `text`, `slug` | inline, old value struck through | +| `textarea` | inline, collapsed behind a disclosure past eight lines | +| `boolean` | a tick or an em-dash | +| `enum` | a badge with the option's label | +| `number` | `tabular-nums` | +| `dateTime` | ``, in the viewer's locale | +| `relation`, `user` | the stored id, as `#3` | +| `null` | the same em-dash the table cells use | + +No raw JSON anywhere. Somebody comparing two versions of an article is looking +for the sentence that changed, and `{"title":"..."}` makes them find it +themselves. + + + A [snapshot](/docs/dev/content-engine/revisions#the-snapshot) stores the + foreign key, deliberately - the display name belongs to another content type, + which may not publish it. Resolving those ids back to names at display time is + not wired up yet, so a changed category currently reads `#3 → #7`. + + +Restoring asks for confirmation and states all four facts: which version, that +it creates a **new** one, that nothing in between is deleted, and that the +publication state does not move. The button is absent without `can_restore`. + +### Preview + +With [`editorial.preview`](/docs/dev/content-engine/preview), an eye icon leads +the actions cell - present only for a content type that can be previewed, absent +rather than disabled for anything else. + +The link is minted **when the popover opens**, never with the table payload. A +page of 25 rows must not be 25 live bearer credentials for unpublished records +sitting in a browser, most of them never used. Closing the popover throws the +link away, so opening it again mints a fresh one instead of showing you one that +may already have expired. + +The URL is absolute - it is going on a clipboard and into somebody else's chat +window - and it points at the web app when `preview.pathTemplate` is set, or at +the API's JSON endpoint when it is not. The popover also says when the link +expires, that it is pinned to one version, and, for a record with no history +yet, that it reads live rather than frozen. + +Without a usable `CONTENT_PREVIEW_SECRET` the server answers 503 and the toast +names the variable, because the person clicking the button is usually the person +who can set it. + +### Scheduling + +With [`editorial.scheduling`](/docs/dev/content-engine/scheduling), a calendar +icon opens a dialog showing what is booked, what already ran and who booked it, +above a two-field form: what should happen, and when. + +- The date field names the timezone it is reading, because "9am" is a question + otherwise. +- An impossible date is refused before the round trip, by the same pure function + the server uses - so the two cannot drift into disagreeing. +- A pending schedule whose time has passed reads **overdue**, with the last error + if there was one. +- A completed schedule whose announcements have not landed says so in different + words and a different colour: the record *is* published, and the event, search + write and cache expiry are being retried. +- Cancelling works until the worker claims the row. After that the request + answers 404, because the schedule already ran - the dialog never claims to + have stopped something it did not. +- Without a cron adapter, a warning sits above everything: schedules will be + saved and will never fire. + +Gated by `can_publish`, like the publish button - booking a publication is +publishing, just later. + +### The conflict + +When another session saved first, the edit dialog **stays open and keeps +everything you typed.** A banner appears above the fields; nothing is merged, +overwritten or reloaded until you say so. + +1. The banner names the version the record moved to. +2. **Show what changed** loads it and lists only the fields that actually moved + remotely - compared against the values the dialog *opened* with, not against + what you have typed since, because the question being answered is "what did I + not see". +3. Saving again is a second, deliberate click, and it posts the new version. + + + Deciding which half of a rewritten paragraph survives is an editorial + judgement. A field-level automatic merge would silently pick one, and be wrong + often enough that nobody could trust the result. + + ## 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 index 139e621ae..0fb6ffa93 100644 --- a/apps/docs/content/docs/dev/content-engine/caching.mdx +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -126,6 +126,10 @@ it. | delete, ever published | ✓ | ✓ | ✓ | — | immediate | | delete, never published | — | — | — | — | — | | publish/unpublish no-op | — | — | — | — | — | +| restore a draft | — | — | — | — | — | +| restore published, same slug | ✓ | ✓ | — | ✓ | stale-while-revalidate | +| restore published, slug changed | ✓ | ✓ | ✓ | ✓ | immediate | +| restore that changed nothing | — | — | — | — | — | Two things worth stating out loud: @@ -138,6 +142,11 @@ Two things worth stating out loud: Nothing global is ever expired, and one content type's mutation never touches another's tags. +[Restore](/docs/dev/content-engine/revisions#restore) has no rules of its own - +it lands on the update rows above, because as far as a visitor is concerned a +restore *is* an update. It cannot appear on the publish or unpublish rows at +all: restoring is structurally incapable of moving `status`. + ### Immediate, or stale-while-revalidate The last column is the difference between two Next APIs, and it matters: @@ -169,13 +178,73 @@ 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" }`. + + That restriction is what buys read-your-own-writes, and every generated write + path is already a server action - so the default is right there. + +From a Route Handler or a webhook, say so and still get an immediate expiry: + +```ts +revalidateContent(input, { context: "route-handler", mode: "immediate" }); +``` + +`route-handler` swaps `updateTag` for `revalidateTag(tag, { expire: 0 })`, the +documented webhook equivalent. Same guarantee for the next reader; only the API +differs. + +### Background work goes over a bridge + +A [scheduled](/docs/dev/content-engine/scheduling) publish runs in the queue, +and the queue does not run in Next - in a split deployment it is plain Node, +where importing `next/cache` throws. So it posts to a small signed Route Handler +in the web app, which does the expiring. + +The post is made by a **second queue task**, dispatched inside the same +transaction as the publication itself. That is what stops a temporary outage +costing an invalidation permanently: the task exists if and only if the +transition committed, it retries on the queue's own backoff, and it never +republishes - so retrying it is only ever another attempt at expiring a tag. + +The bridge itself never throws. The task around it fails unless **every** +configured origin accepted the request, and that is what triggers the retry. + +### Every origin, not the first one + +An install can serve several web apps from one API: + +```ts +content: { + revalidateOrigins: [ + "https://app.example.com", + "https://docs.example.com", + ], +} +``` + +Each is posted independently, so one being down does not stop the others - they +are separate deployments with separate caches, and a stale page on one is not a +reason for a stale page on all of them. But partial delivery is **not** success: + +| Attempted | Delivered | Result | +| --- | --- | --- | +| 0 | 0 | Nothing to expire, or no origin configured. Not an outage | +| 1 | 1 | Delivered | +| 1 | 0 | Retried | +| 2 | 1 | **Retried** | +| 2 | 2 | Delivered | + +The dangerous row is `2 / 1`. After a scheduled unpublish it means one web app +expired its cache and the other is still serving a page that was withdrawn - +and calling that a success would mean the second one never gets another chance. +The retry re-posts to every origin, including the one that already worked; +expiring an already-expired tag is a no-op, so that is cheaper than tracking +which of them succeeded. + +Delivery is at-least-once. The reasoning, the auth and the payload are all in +[Scheduled publishing](/docs/dev/content-engine/scheduling#the-cache-bridge). + ### The slug change An update needs both slugs: the old URL has to stop resolving and the new one 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 3e65fe613..21ffcc41a 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 @@ -29,6 +29,12 @@ status: varchar(32) not null default 'draft' publishedAt: timestamp // "first published at", nullable ``` +and one with [`editorial`](/docs/dev/content-engine/editorial) gets one more: + +```ts +version: integer not null default 1 +``` + Here is the real migration for the example plugin: ```sql title="apps/docs/migrations/0022_add_example_content.sql" @@ -208,6 +214,123 @@ A clean database just runs the journal in order. Content tables are plain `CREATE TABLE` statements; there is no bootstrap step and no ordering subtlety beyond the foreign keys Drizzle already sorts out. +## The shared revisions table + +[`editorial`](/docs/dev/content-engine/editorial) does **not** generate a +revisions table per content type. There is one, in core, shared by every +editorial content type in the install: + +```sql title="apps/docs/migrations/0025_add_content_revisions.sql" +CREATE TABLE "core_content_revisions" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "version" integer NOT NULL, + "operation" varchar(20) NOT NULL, + "snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL, + "changedFields" jsonb DEFAULT '[]'::jsonb NOT NULL, + "actorType" varchar(16) DEFAULT 'system' NOT NULL, + "actorUserId" integer, + "restoredFromRevisionId" integer, + "createdAt" timestamp DEFAULT now() NOT NULL +); +ALTER TABLE "core_content_revisions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "core_content_revisions" ADD CONSTRAINT "core_content_revisions_actorUserId_core_users_id_fk" + FOREIGN KEY ("actorUserId") REFERENCES "public"."core_users"("id") + ON DELETE set null ON UPDATE cascade; +CREATE UNIQUE INDEX "core_content_revisions_item_version_unique" + ON "core_content_revisions" USING btree ("contentTypeId","itemId","version"); +CREATE INDEX "core_content_revisions_plugin_id_idx" + ON "core_content_revisions" USING btree ("pluginId"); +CREATE INDEX "core_content_revisions_actor_user_id_idx" + ON "core_content_revisions" USING btree ("actorUserId"); +``` + +Two things about it are worth knowing before you go looking for them. + +**There is no foreign key to the content row.** There cannot be: content tables +are produced at runtime from a descriptor, and core's static schema has no name +to point at. `core_search_index` models the same relationship the same way, with +`itemType` and `itemId` and no constraint. So deleting a record leaves its +history behind - deliberately, that being what an audit trail is - and every +query is scoped by `(pluginId, contentTypeId, itemId)` instead of relying on +the database to police ownership. + +**The unique index is the real invariant.** `(contentTypeId, itemId, version)` +is what makes "one revision per version" true even if two writes race, rather +than true because the code is careful. It is also the history index: newest-first +is a backwards scan of it. + +`restoredFromRevisionId` is a plain integer with no constraint on purpose. It +points at a revision that retention will eventually prune, and a foreign key +would either block that prune or null out the reference - both of which lose the +one fact the column exists to record. + +Adding `editorial` to an existing content type is one statement against the +content table itself, and no backfill: + +```sql title="apps/docs/migrations/0026_add_example_article_editorial.sql" +ALTER TABLE "example_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL; +``` + +### The schedules table + +[`editorial.scheduling`](/docs/dev/content-engine/scheduling) adds a second +shared core table, `core_content_schedules`, on the same terms: no foreign key +to the record, every query scoped by `(pluginId, contentTypeId, itemId)`, and no +per-plugin migration. + +Its one interesting index is partial: + +```sql +CREATE UNIQUE INDEX "core_content_schedules_active_unique" + ON "core_content_schedules" USING btree ("contentTypeId","itemId","action") + WHERE status = 'pending'; +``` + +At most one *pending* schedule per record and action, enforced by the database. +That is what makes "cancel the old row, then insert a new one" safe when two +requests arrive together - and it still allows any number of settled rows, which +are the audit trail. + +Completed and cancelled rows are kept and swept by a daily cron rather than +deleted on success. "Who scheduled this, and when did it go out" is the question +the feature exists to answer. + +It carries two error columns rather than one, because a scheduled publication is +two units of work: + +| Column | Means | +| --- | --- | +| `lastError` | The **transition** failed. The row is still `pending` and the queue is retrying the publish | +| `effectsError` | The transition committed and the row is `completed`, but its event, search write or cache expiry has not landed yet | + +A completed schedule is never moved back to `pending` because an announcement +bounced - the record really did publish, and only the telling is outstanding. + +### When a plugin or content type is renamed + +Revisions are keyed by `pluginId` and `contentTypeId` as strings, so renaming +either one orphans the rows - the same as it does for the +[search index](/docs/dev/content-engine/search). One statement fixes it, and it +belongs in the migration that does the rename: + +```sql +UPDATE "core_content_revisions" +SET "contentTypeId" = 'example.post' +WHERE "contentTypeId" = 'example.article'; + +UPDATE "core_content_schedules" +SET "contentTypeId" = 'example.post' +WHERE "contentTypeId" = 'example.article'; +``` + +Removing a content type for good needs no statement at all: the daily +`content-editorial-cleanup` cron deletes rows whose content type is no longer +registered, on both tables. A rename looks exactly like a removal to it, which +is why the `UPDATE` belongs in the same migration rather than the next release. + ## Renaming or removing a field diff --git a/apps/docs/content/docs/dev/content-engine/editorial.mdx b/apps/docs/content/docs/dev/content-engine/editorial.mdx new file mode 100644 index 000000000..5626c4fac --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/editorial.mdx @@ -0,0 +1,270 @@ +--- +title: Editorial workflow +description: One opt-in block that adds a version column, optimistic locking and a revision per mutation - and nothing at all if you leave it out. +icon: PenLine +--- + +Two editors open the same article. One saves at 10:04, the other at 10:05. The +second save wins completely, the first one is gone, and nobody is told. + +`editorial` is the block that stops that happening. + +```ts title="src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + fields: { + /* ... */ + }, + + editorial: { + enabled: true, + revisions: { retention: 20 }, + }, +}); +``` + +That is the whole opt-in. A content type without it generates the same table, +the same routes and the same wire shapes it always did. + +## What it turns on + +| | | +| --- | --- | +| A generated `version` column | `integer not null default 1`, bumped by every real write | +| Optimistic locking | A write carries the version it expects, and loses if the record moved | +| [Revision history](/docs/dev/content-engine/revisions) | One row in `core_content_revisions` per real mutation | +| Restore | Put an earlier version's field values back, as a new version | +| Three routes | `GET /{id}/revisions`, `GET /{id}/revisions/{revisionId}`, `POST /{id}/revisions/{revisionId}/restore` | +| Optionally, [preview](/docs/dev/content-engine/preview) | Signed, expiring links to an unpublished record | +| Optionally, [scheduling](/docs/dev/content-engine/scheduling) | Publish or unpublish at a set time | +| One permission | `can_restore`, depending on `can_edit` | +| AdminCP | A history dialog with a field-level diff, and a conflict banner instead of a lost edit | + +Locking and history arrive together, deliberately. A `version` column with no +history is a lock with no audit trail - it can tell you that you lost, but not +what you lost - and "locking without history" is a knob nobody has ever wanted. + +## The block + +```ts +editorial: { + enabled: true, + + // Optional. How many of the newest versions are kept per record. + revisions: { retention: 50 }, +} +``` + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | `true` | — | Adds the `version` column, the revision history and the restore route | +| `revisions.retention` | `number`, 1–500 | `50` | Newest versions kept per record. Older ones are pruned in the same transaction that writes the new one | + +Everything resolves to a complete object, so `definition.editorial.revisions.retention` +is always a number and never `undefined`: + +```ts +// editorial omitted +{ + enabled: false, + preview: { enabled: false, expiresInMinutes: 15, pathTemplate: null }, + revisions: { retention: 50 }, + scheduling: { enabled: false }, +} +``` + +Two more features live inside the same block, each with its own literal +`enabled: true`: + +```ts +editorial: { + enabled: true, + preview: { enabled: true, expiresInMinutes: 30 }, + scheduling: { enabled: true }, +} +``` + +- [`preview`](/docs/dev/content-engine/preview) - signed, expiring links that + let a reviewer read an unpublished record without an account. +- [`scheduling`](/docs/dev/content-engine/scheduling) - publish or unpublish at + a set time, on a one-minute tick. + + + At least 32 random bytes - `openssl rand -base64 32`. The signature is the + only access control a preview link has, so without one the API refuses to + start in production, and preview fails closed everywhere else. See + [Secure preview](/docs/dev/content-engine/preview#content_preview_secret-is-required). + + +## `version` is generated, so you cannot declare it + +Exactly like `status` and `publishedAt` under +[`publication`](/docs/dev/content-engine/publication): the column belongs to the +engine, so the name is reserved the moment you opt in. + +```ts +editorial: { enabled: true }, +fields: { + version: field.number(), + // ^ Compile error, and a boot error if you cast past it. +} +``` + +Without `editorial`, `version` is an ordinary field name and stays yours. + +It is readable everywhere a system column is - `ContentSelect`, the select +schema, the list response and the sort allowlist - and you can name it in +`admin.list.columns` to show it in the table, where it renders as the number it +is. + +It is writable nowhere. `schemas.create` and `schemas.update` are strict objects +of declared fields, so `version` cannot be sent, spoofed or mass-assigned. The +only thing that moves it is a successful write. + +## Capability rules + +`editorial` works on its own. The two sub-features have prerequisites, and both +are enforced twice - once by the compiler, once at boot: + +| Rule | Why | +| --- | --- | +| `editorial` needs nothing | Locking and history are orthogonal to the lifecycle. A content type with no `publication` and no `publicApi` can be editorial | +| `preview` needs [`publicApi`](/docs/dev/content-engine/public-api) | A preview shows the *public* projection of a draft. With no public allowlist there is nothing it could safely render | +| `scheduling` needs [`publication`](/docs/dev/content-engine/publication) | A schedule moves `status`. Without the lifecycle there is no status to move | + +```ts +defineContentType({ + publication: { enabled: false }, + editorial: { + enabled: true, + scheduling: { enabled: true }, + // ^ Compile error: not assignable to `{ enabled: false }` + }, +}); +``` + +The compile error is the useful one, but the runtime check is the guarantee - a +`ContentEngineError` at boot, naming the content type and the rule, so a cast or +a plain-JavaScript config cannot slip past. + +## What changes on the wire + +One thing, and only for content types that opted in: **`PUT /{id}` takes an +envelope.** + +```jsonc +// Without editorial - unchanged, forever +{ "title": "Hello world" } + +// With editorial +{ "expectedVersion": 12, "values": { "title": "Hello world" } } +``` + +`expectedVersion` sits *beside* `values` rather than inside it, because `values` +is validated by `schemas.update` - a strict object of declared fields, which a +transport concern has no business appearing in. + +`DELETE /{id}` gains the same precondition, on its own: + +```jsonc +// Without editorial - no body, forever +// With editorial +{ "expectedVersion": 12 } +``` + +`POST`, `publish` and `unpublish` keep the bodies they had; the routes are +generated per content type, so each one's OpenAPI document stays truthful about +its own shape. + + + A client that sends a bare `PUT` body - or a bodyless `DELETE` - to a content + type that has just enabled `editorial` gets a 400. There is no way around that + and still have a correct precondition: the whole point is that the server + knows which version you were looking at. It is opt-in per content type, so you + choose when. + + +The full contract - what a 409 looks like, when a version moves, what restore +does - is in [Revisions and locking](/docs/dev/content-engine/revisions). + +## What it announces + +Every real editorial mutation goes through one helper once its transaction has +committed - `contentEditorialEffects` - which emits the operation's event and +syncs the [search](/docs/dev/content-engine/search) document. The routes call +it, and so does the scheduled-publication task, so "which event, and which +search operation" is decided in one place rather than three that can drift. + +Two properties are worth knowing about, because they are easy to assume the +other way round: + +- **The event envelope is owned by the plugin that owns the content type**, not + by whichever module emitted it. That matters as soon as something runs on your + behalf: core owns the scheduled-publication queue handler, and + `content.example.article.published` is still the example plugin's event. +- **Nothing throws.** `emit()` reports listener failures in its result rather + than raising them, because the mutation has already committed and a broken + listener is not a reason to fail somebody's save. `contentEditorialEffects` + hands both outcomes back: + +```ts +const { event, search } = await contentEditorialEffects(c, def, outcome, { + pluginId, +}); + +event?.failures; // listeners that fell over, [] when all of them ran +search?.error; // set when the index refused the write +``` + +Interactive routes ignore both. Background work should not: the +[scheduled-effects task](/docs/dev/content-engine/scheduling#all-three-have-to-land) +reads them and retries when either is unhappy. + +## Permissions + +One new permission, generated only for editorial content types: + +```text +can_restore POST /{id}/revisions/{revisionId}/restore +``` + +It depends on `can_edit`. Reading history needs `can_view`, like every other +read - but *restoring* rewrites many fields at once, and someone who cannot edit +must not reach the same outcome through the back door. + +No migration: the permission catalog is derived in code, so the entry simply +appears in the staff editor. See +[Generated permissions](/docs/dev/content-engine/permissions). + +## Migrations + +Enabling `editorial` on an existing content type is one additive statement: + +```sql title="apps/docs/migrations/0026_add_example_article_editorial.sql" +ALTER TABLE "example_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL; +``` + +Every existing row becomes version 1 in a single pass. No backfill script, no +history invented for edits that happened before you were recording them - the +first edit after the migration produces version 2 and the first revision. + +The shared `core_content_revisions` table comes from core's own migration, so a +plugin adding `editorial` never migrates it. + +## Turning it off + +Set `enabled: false` and the column, routes, permission and history UI all +disappear - the definition is the only source of truth. Dropping the column is +then an ordinary migration. + +The revision rows for that content type stay behind, orphaned by `contentTypeId`, +in exactly the way [search collections](/docs/dev/content-engine/search) do when +a content type drops its `search` block. Nothing reads them and nothing prunes +them for you. + + + Drizzle generates no down migrations, and `core_content_revisions` is the only + copy of the history. The safe rollback is to set `enabled: false` in code and + leave the rows where they are. + diff --git a/apps/docs/content/docs/dev/content-engine/events.mdx b/apps/docs/content/docs/dev/content-engine/events.mdx index 64411d561..712120006 100644 --- a/apps/docs/content/docs/dev/content-engine/events.mdx +++ b/apps/docs/content/docs/dev/content-engine/events.mdx @@ -20,6 +20,20 @@ content.example.article.published content.example.article.unpublished ``` +And one with [`editorial`](/docs/dev/content-engine/editorial): + +```text +content.example.article.restored +``` + +Plus two more with +[`editorial.scheduling`](/docs/dev/content-engine/scheduling): + +```text +content.example.article.scheduled +content.example.article.schedule_cancelled +``` + Payloads stay minimal - the [envelope](/docs/dev/events) already carries the actor, the emitting plugin and the timestamp: @@ -29,8 +43,35 @@ type Updated = { changedFields: string[]; contentId: number }; type Deleted = { contentId: number }; type Published = { contentId: number; publishedAt: Date }; type Unpublished = { contentId: number }; + +type Restored = { + changedFields: string[]; + contentId: number; + restoredFromRevisionId: number; + revisionId: number; + version: number; +}; + +type Scheduled = { + action: "publish" | "unpublish"; + actorUserId: null | number; + contentId: number; + scheduledFor: Date; + scheduleId: number; +}; +type ScheduleCancelled = { + action: "publish" | "unpublish"; + actorUserId: null | number; + contentId: number; + scheduleId: number; +}; ``` +`published` and `unpublished` also gained an optional `scheduledBy`, set when a +schedule fired them. Optional, so no existing listener changes - and it is the +only way to answer "the system did it, on whose instruction", because the actor +of a scheduled run genuinely *is* the system. + ## Registering the types One `declare module` block per plugin adds them to the global event map, using @@ -95,8 +136,9 @@ it is worth being precise: | | Emits | | --- | --- | -| `POST`, `PUT`, `DELETE`, `POST /{id}/publish`, `POST /{id}/unpublish` | yes, once, after the write returns | +| `POST`, `PUT`, `DELETE`, `POST /{id}/publish`, `POST /{id}/unpublish`, `POST /{id}/revisions/{revisionId}/restore` | yes, once, after the write returns | | `service.create()`, `update()`, `delete()`, `publish()`, `unpublish()` | no | +| `editorialService.create()`, `update()`, `restore()`, … | no - it may be inside your transaction | A generated route emits once the database write has returned. A create that @@ -115,6 +157,51 @@ and `unpublished` never come with an `updated` alongside them - `status` and be nothing truthful to put in `changedFields`. Subscribe to all five if you want "anything changed". +`restored` follows the same rule: it arrives alone, never with an `updated` +beside it. It carries `changedFields` for exactly that reason, so a listener +written for `updated` ports across in one line. + +Scheduling is the exception that proves it. Booking a publication changes no +field value, consumes no version and writes no revision, so it emits +`scheduled` - a different thing entirely. When the schedule actually fires, the +resulting transition emits the ordinary `published` or `unpublished`, once. + +### Calling `editorialService` directly + +The [editorial service](/docs/dev/content-engine/revisions#calling-the-service-directly) +emits nothing either, and it owns a transaction, so the timing rule is even more +literal there: the content write, the version bump and the revision insert are +one commit, and every announcement happens after it. One helper makes those +decisions for you, and it is the same one the routes use: + +```ts +import { contentEditorialEffects } from "@vitnode/core/content/server"; + +const outcome = await editorial.update(id, values, { actor, expectedVersion }); +if (!outcome) throw new HTTPException(404); + +// Emits the right event for the operation, and syncs search. A no-op outcome +// returns immediately, so a pointless save announces nothing. +const { event, search } = await contentEditorialEffects( + c, + articleContentType, + outcome, + { pluginId }, +); +``` + +Both outcomes come back, and neither throws. `event.failures` lists listeners +that fell over, `search.error` is set when the index refused the write, and both +are `null` for a no-op. An interactive route ignores them - the mutation +committed, and the person is owed a 200 either way. Background work that must be +retried reads them and decides, which is exactly what +[scheduled effects](/docs/dev/content-engine/scheduling#all-three-have-to-land) +do. + +`pluginId` is not only for the search document: it is also the owner stamped on +the event envelope, so `content.example.article.updated` belongs to the example +plugin whether a route, a cron job or a queue handler emitted it. + ### Calling the service directly The service is a repository, not an application layer. It changes rows and diff --git a/apps/docs/content/docs/dev/content-engine/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx index 71e66973d..e035827f5 100644 --- a/apps/docs/content/docs/dev/content-engine/index.mdx +++ b/apps/docs/content/docs/dev/content-engine/index.mdx @@ -44,15 +44,19 @@ 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: +Three more declarations, each opt-in: - [`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) +- [`editorial`](/docs/dev/content-engine/editorial) adds a `version` column, + optimistic locking so two editors cannot silently overwrite each other, and a + [revision history](/docs/dev/content-engine/revisions) you can restore from -Publication alone exposes nothing. Public exposure requires both. +Publication alone exposes nothing. Public exposure requires both of the first +two; `editorial` works with or without either. ## 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 c9759c68b..37fbddd18 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -14,8 +14,13 @@ 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 | Not generated; the lifecycle stops at draft/published | -| Scheduled publishing | `publish()` means now. The predicate already reads `publishedAt <= now()`, so this is additive later | +| To-the-second [scheduling](/docs/dev/content-engine/scheduling) | The queue drains on a one-minute tick, so a schedule fires within about a minute | +| Scheduling a field edit, or a recurring schedule | Only `status` is scheduled. One row, one time, one action | +| Revoking a single [preview link](/docs/dev/content-engine/preview) | Tokens are stateless. Rotate `CONTENT_PREVIEW_SECRET`, or wait out the expiry | +| Keeping a previewed revision from being pruned | A link is pinned to one revision, and retention can remove it before the link expires. The TTL is a maximum, not a promise | +| A preview **list** of drafts | Only one record at a time, by signed link. There is no list route and there will not be one | +| Approval workflows, reviewer assignment, per-locale revisions | [`editorial`](/docs/dev/content-engine/editorial) records what happened; it does not gate who may do it beyond the staff permissions | +| Field-level merge of a conflicting edit | The [conflict banner](/docs/dev/content-engine/revisions#the-conflict) shows both sides and lets a person choose | | 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 | @@ -187,6 +192,46 @@ The AdminCP shows that as **stale** rather than hiding it. Extra documents, for records that no longer qualify, are stale too: the counts have to match exactly for a collection to read as indexed. +## Background effects are at-least-once + +A [scheduled](/docs/dev/content-engine/scheduling) publish cannot expire a Next +cache tag itself - the queue does not run in Next - so it asks the web app over +a signed HTTP hop, from a **second** queue task dispatched inside the same +transaction as the transition. + +That is what makes the delivery durable: the task exists if and only if the +publication committed, it retries on the queue's own backoff, and it never +republishes anything. A web app that was redeploying gets its tags expired on +the next attempt rather than never. + +The price is the usual one for a retry without an outbox: **effects can be +delivered more than once.** A search upsert and a cache expiry are idempotent by +construction, but a listener may see the same `published` event twice. Key off +`scheduleId` or `revisionId` if that matters to you. There is no exactly-once +guarantee, and this page will not pretend otherwise. + +## History is bounded, and restore is not undelete + +[`editorial`](/docs/dev/content-engine/editorial) keeps the newest `retention` +versions of a record and prunes the rest in the same transaction that writes the +new one. That bound is the feature - an install with no cron adapter must not +grow forever - but it means the oldest history is genuinely gone, not archived +somewhere. + +Two more things it deliberately does not do: + +- **Restoring a deleted record.** A delete writes a final revision and keeps the + earlier ones, so you can still read what was there. Putting the row back would + mean reinstating a primary key another row may since have taken. +- **Branching.** History is a line, not a tree. Restoring an old version creates + a new one on top; there is no way to fork a record and merge it later. + +Revisions also survive the content type that wrote them. Dropping the +`editorial` block, renaming the content type id or removing the plugin leaves +the rows orphaned by `contentTypeId` - the same story as an orphaned search +collection, and with the same fix: a one-line `UPDATE` when it was a rename, and +a `DELETE` when it was not. + ## The public cursor is always the row id `withPagination` paginates on the primary key, so a list sorted by `publishedAt` diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index 844221bf8..f09f7373a 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -15,6 +15,10 @@ "public-service", "search", "caching", + "editorial", + "revisions", + "preview", + "scheduling", "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 3bc4cc111..0905dc56f 100644 --- a/apps/docs/content/docs/dev/content-engine/permissions.mdx +++ b/apps/docs/content/docs/dev/content-engine/permissions.mdx @@ -28,6 +28,22 @@ 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. +A content type with [`editorial`](/docs/dev/content-engine/editorial) gets a +sixth, and this one depends on `can_edit`: + +```text +can_restore POST /{id}/revisions/{revisionId}/restore +``` + +Reading history is an ordinary read, so it needs only `can_view`. Restoring +rewrites many fields at once, and somebody who is not trusted to edit the record +must not reach the same outcome through its history. + +No permission is added for +[scheduling](/docs/dev/content-engine/scheduling): booking a publication *is* +publishing, just later, so it reuses `can_publish` rather than inventing a gate +that could be granted separately. + ## What each route documents Every status a generated handler can produce is in the OpenAPI document, so a @@ -45,10 +61,27 @@ unique-constraint `409`: | `POST /{id}/unpublish` | `200`, `400`, `404` | | `DELETE /{id}` | `200`, `400`, `404`, `409` | +With [`editorial`](/docs/dev/content-engine/editorial), three more: + +| Route | Statuses | +| --- | --- | +| `GET /{id}/revisions` | `200`, `400` | +| `GET /{id}/revisions/{revisionId}` | `200`, `400`, `404` | +| `POST /{id}/revisions/{revisionId}/restore` | `200`, `400`, `404`, `409`, `422` | +| `POST /{id}/preview` (with `editorial.preview`) | `200`, `400`, `404` | +| `GET /{id}/schedules` (with `editorial.scheduling`) | `200`, `400` | +| `POST /{id}/schedule` | `200`, `400`, `404` | +| `POST /{id}/schedule/{scheduleId}/cancel` | `200`, `400`, `404` | + A `409` on create or update is a duplicate value; on delete it is a row something -else still references. `403` is the one status the routes do not declare -themselves - it comes from the staff-permission middleware `buildRoute` composes, -the same way it does for every other VitNode route. +else still references. On an editorial content type it also covers a lost +update, which is why those routes answer with a JSON body carrying a +[`code`](/docs/dev/content-engine/revisions#the-409) rather than a sentence - one +status, two situations, and a client has to tell them apart. + +`403` is the one status the routes do not declare themselves - it comes from the +staff-permission middleware `buildRoute` composes, the same way it does for +every other VitNode route. ## The module name @@ -102,7 +135,8 @@ the flat key convention: "@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_articles:can_publish": "Publish and unpublish articles", + "@vitnode/example:example_articles:can_restore": "Restore an earlier version" } ``` @@ -114,7 +148,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_publish`. + `can_delete` / `can_publish` / `can_restore`. 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/preview.mdx b/apps/docs/content/docs/dev/content-engine/preview.mdx new file mode 100644 index 000000000..eb387bbc9 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/preview.mdx @@ -0,0 +1,337 @@ +--- +title: Secure preview +description: Signed, expiring links that let a reviewer read an unpublished record - without an account, and without publishing it first. +icon: Eye +--- + +Before this existed, showing someone a draft meant publishing it, sending the +link, and unpublishing again. Preview is the honest version of that. + +```ts title="src/content/article.ts" +editorial: { + enabled: true, + preview: { enabled: true, expiresInMinutes: 30 }, +} +``` + +That adds two routes: one behind the AdminCP that mints a link, and one that is +completely public and reads it. + +## The rules it follows + +| | | +| --- | --- | +| **The link is the credential** | No session, no account, no invitation. A reviewer opens a URL | +| **It expires** | 15 minutes by default, a day at most | +| **It shows one revision** | Frozen at the moment the link was made, so it does not shift under the reviewer | +| **It shows only public fields** | The same allowlist the public detail route uses - the same function, not a copy of it | +| **Nothing caches it** | `private, no-store`, and `noindex, nofollow` in case the link is pasted somewhere public | +| **Every failure is a 404** | Forged, expired, wrong record, no such record: indistinguishable | + +## Configuration + +```ts +editorial: { + enabled: true, + preview: { + enabled: true, + expiresInMinutes: 30, + pathTemplate: "/articles/preview/{token}", + }, +} +``` + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | `true` | — | Adds both preview routes | +| `expiresInMinutes` | `number`, 1–1440 | `15` | How long a link stays valid | +| `pathTemplate` | `string` | `null` | A page in your app. Exactly one `{token}`, leading `/`, no `..` | + +Without `pathTemplate` the AdminCP links straight at the JSON endpoint. That is +the honest default: linking to a page nobody has written yet would just be a +404 with extra steps. + + + `editorial.preview` needs + [`publicApi`](/docs/dev/content-engine/public-api), which itself needs + [`publication`](/docs/dev/content-engine/publication). A preview renders the + *public projection* of a draft, so with no allowlist there is nothing it could + safely show. Turning it on without one is a compile error, and a boot error if + you cast past it. + + +## `CONTENT_PREVIEW_SECRET` is required + +**Preview does not work without one.** This single value is the entire +authorization story - there is no session to fall back on - so a missing or +guessable secret is not a warning, it is every draft on the site readable by +anyone who has read the VitNode source. + +```bash +openssl rand -base64 32 +``` + +```bash title=".env" +CONTENT_PREVIEW_SECRET=Q0hBTkdFLU1FLXRoaXMtaXMtYW4tZXhhbXBsZS12YWx1ZQ== +``` + +A secret is acceptable when all three are true: + +```text +CONTENT_PREVIEW_SECRET is set +it is not the built-in placeholder +it is at least 32 bytes +``` + +Anything else and preview **fails closed**, everywhere: + +| Where | What happens | +| --- | --- | +| Boot, in production | The API refuses to start, naming the content types that made it mandatory | +| Boot, in development | A warning on stdout. The app starts; preview does not | +| `POST /{id}/preview` | **503**, with a message that names the variable | +| `GET /content/{path}/preview/{token}` | **404** - the same answer a forged token gets, so an anonymous request learns nothing about the deployment | +| AdminCP → System → Integrations | `contentPreview.secure: false`, next to the same flag for `CRON_SECRET` | + + + Refusing to start `pnpm dev` over a missing secret would be rude. Serving + drafts to anyone who guesses a URL would be worse. So a development install + boots and preview simply does not work until you set one - and the 503 says + exactly that, rather than failing somewhere unhelpful. + + + + Next imports every route module while collecting page data, so the API's boot + check runs on the build machine too - which has no business holding a runtime + signing key. The build logs the warning and carries on; the process that + actually serves requests still refuses to start. + + + + There is no revocation list, and no per-link kill switch. Changing the secret + invalidates every outstanding link at once, which is the blunt instrument you + want when one leaks. Short expiries are what keep you from needing it. + + +## The routes + +```text +POST /api/{pluginId}/admin/content/{module}/{id}/preview can_view +GET /api/{pluginId}/content/{publicApi.path}/preview/{token} nobody +``` + +Creating a link needs `can_view`, and nothing more: a preview shows exactly what +the public route would, so anyone allowed to open the record in the AdminCP can +already see it. + +Reading one needs no permission at all, deliberately - that is the entire point, +and it is asserted by a test rather than left to review. + +```jsonc +// POST /{id}/preview +{ + "token": "eyJhdWQiOiJjb250ZW50LXByZXZpZXci…", + "url": "https://example.com/articles/preview/eyJhdWQiOiJjb250ZW50LXByZXZpZXci…", + "expiresAt": "2026-08-05T10:30:00.000Z", + "revisionId": 42, + "version": 5 +} +``` + +### `url` is always absolute + +It goes on somebody's clipboard and into somebody else's chat window, so a path +would be useless - and in a split deployment it would resolve against the wrong +host. Which origin it resolves against depends on where the link actually points: + +| `preview.pathTemplate` | Resolved against | Example | +| --- | --- | --- | +| set | `NEXT_PUBLIC_WEB_URL` - your app renders the page | `https://example.com/articles/preview/{token}` | +| unset | `NEXT_PUBLIC_API_URL` - the generated JSON endpoint | `https://api.example.com/api/@vitnode/example/content/articles/preview/{token}` | + +Two different origins, deliberately: the page is served by the web app and the +endpoint by the API, and assuming they share a host is exactly the assumption a +split deployment breaks. Both are validated at boot when preview is enabled, so +a malformed `NEXT_PUBLIC_WEB_URL` is a startup error rather than a broken link +handed to a reviewer. + +`url` is built on the server, because only the definition knows whether this +install has a preview page or should link at the JSON endpoint. The token is +percent-encoded into the path. + + + `/preview/{token}` is two path segments and `/{slug}` is one, so they can never + both match. A record whose slug is literally `preview` resolves the ordinary + way, and there is a test that says so. + + +## The token + +Stateless, HMAC-SHA256, no new dependency and no table: + +```text +base64url(payload) "." base64url(hmacSha256(secret, payload)) +``` + +```ts +{ + aud: "content-preview", // rejects a token minted for anything else + v: 1, // the token format + p: "@vitnode/example", // plugin + t: "example.article", // content type + i: 7, // record + r: 42, // revision - what makes it frozen + ver: 5, // the row version when the link was made + exp: 1785924600 // epoch seconds +} +``` + +The payload is **readable by anyone holding the link** - base64url is encoding, +not encryption. The signature only proves nobody edited it. Nothing secret goes +in there. + +`p` and `t` are checked against the route's own definition rather than trusted +from the payload. Without that, one signed token would work on every preview +route in the install. + +### Expiry has no leeway + +`exp <= now` is rejected, full stop. Web and API already need agreeing clocks +for sessions to work at all, and slack on an expiry only ever weakens it. + +### Replay is allowed, on purpose + +A link is meant to be forwarded to a reviewer, so it works as many times as it +is opened until it expires. It is a link, not a nonce. + +## Rendering a preview page + +The API returns JSON. Turning that into a page is your app's job, and it is +about fifteen lines: + +```tsx title="src/app/articles/preview/[token]/page.tsx" +import { articleContentType } from "@/content/article"; +import { contentPreviewFetch } from "@vitnode/core/content/next"; +import { notFound } from "next/navigation"; + +export default async function ArticlePreviewPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { data } = await contentPreviewFetch({ + definition: articleContentType, + pluginId: "@vitnode/example", + token: (await params).token, + }); + + if (!data) notFound(); + + return ( +
+

Preview - not published

+

{data.title}

+

{data.excerpt}

+
+ ); +} +``` + +`contentPreviewFetch` is the mirror image of +[`contentPublicFetch`](/docs/dev/content-engine/caching#reading): it sends +`cache: "no-store"` and attaches no tags. Storing a preview response would keep +a draft readable after its token expired, and would hand one reviewer's link to +the next visitor. + +Then point the AdminCP at it: + +```ts +preview: { enabled: true, pathTemplate: "/articles/preview/{token}" } +``` + + + Nothing in the response says so - it is the same shape the public route + returns. Say it in your own markup, or somebody will forward a screenshot of + what they think is the live page. + + +## What the AdminCP does + +An eye icon on every row, for a content type with preview and for nobody else - +absent rather than disabled, because there is no way to enable it from the UI. + +Clicking it mints the link **then**, not before. A table of 25 rows must not be +25 live bearer credentials for unpublished records sitting in a browser, most of +them never used. The popover shows the link, a copy button, an Open link and +when it expires. + +Closing the popover throws the link away, so opening it again mints a fresh one +rather than showing you one that may already have expired. + +## What it does not do + +| Not supported | Why | +| --- | --- | +| Revoking one link | No table, no state. Rotate the secret, or wait out the expiry | +| Previewing a **list** of drafts | There is no preview list route, and there will not be one | +| Seeing who opened a link | It is an anonymous read. Nothing is logged per view | +| Previewing a record with no public API | There would be nothing safe to render | +| Keeping a previewed revision from being pruned | That needs persistent preview records. Retention wins, and the link 404s | +| Comments or annotations on a preview | Read-only. A reviewer replies wherever they already talk to you | +| Working without `CONTENT_PREVIEW_SECRET` | The signature *is* the access control. Missing means off, in every environment | + +## Why it is safe + +The properties worth stating plainly, because each one is a decision rather than +an accident: + +- **Forgery** needs the secret. It is a 256-bit HMAC over the whole payload, + compared with `timingSafeEqual`. +- **Field exposure** goes through `createContentPublicProjector` - the *same* + function the public detail route uses. A test adds a private field and asserts + it is absent from the body, and that it never reaches the `SELECT` either. +- **Cross-plugin and cross-content-type access** is closed by checking `p` and + `t` against the route's own definition, and by scoping the revision read to + `(pluginId, contentTypeId, itemId)` like every other revision query. +- **Existence probing** is closed by returning one 404 for everything. A 401 or a + 403 would confirm the record exists, which is the one thing a draft URL must + not do. +- **Leaked links** are bounded by the expiry, kept out of shared caches by + `no-store` and out of search results by `noindex`. + +## A record with no history yet + +Adding `editorial` to a content type that already has rows leaves those rows at +version 1 with no revisions - there is nothing to freeze. Their preview links +carry `revisionId: 0` and read the **live row** instead, still scoped to the +record in the signed token and still projected through the public allowlist. +Only the frozen-snapshot guarantee is missing, because there is nothing to +guarantee: such a link follows any edit made before the reviewer opens it. + +The AdminCP says so in the popover rather than letting "preview" imply something +this one link cannot deliver, and the first edit fixes it permanently. + +Fabricating a snapshot at mint time was the alternative, and it was rejected: +inventing a revision that claims the record was written now puts a lie in the +audit trail to smooth over a case that disappears on the next save. + +## The expiry outlives the revision + +A link is pinned to one revision, and +[retention](/docs/dev/content-engine/revisions#retention) keeps only the newest +`retention` of them. So a busy record can prune the revision a shared link +points at **before** that link expires, and the link 404s early. + +That is accepted behaviour, not a bug to route around: + +- Links are minted against the **newest** revision, which is the last one to be + pruned - so it takes many saves and a slow reviewer. +- Raise `revisions.retention` if your editors work in bursts, or shorten + `expiresInMinutes` so the two windows match. +- The popover says the link is pinned to a version and can age out. + + + "Expires in 30 minutes" means *no later than* 30 minutes. Protecting the + referenced revision from pruning would need a table of live previews, which is + the persistent-preview feature this deliberately does not have. + 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 55bc87477..174c4405e 100644 --- a/apps/docs/content/docs/dev/content-engine/public-api.mdx +++ b/apps/docs/content/docs/dev/content-engine/public-api.mdx @@ -53,6 +53,7 @@ export const articleContentType = defineContentType({ | | | | --- | --- | | Two routes | `GET /content/{path}/` and `GET /content/{path}/{slug}` | +| Optionally a third | `GET /content/{path}/preview/{token}`, with [`editorial.preview`](/docs/dev/content-engine/preview) | | A service | [`model.publicService(c)`](/docs/dev/content-engine/public-service) | | A projection | `ContentPublicSelect` - exactly the allowlist | | OpenAPI | two `get` operations, no others | @@ -283,8 +284,12 @@ probe either. ## 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 +No public writes, ever - under any configuration, including +[preview](/docs/dev/content-engine/preview), which is a `get`. No +`Cache-Control` headers on the two published routes; HTTP caching is handled +Next-side by [tags](/docs/dev/content-engine/caching), and a preview response +is the one exception - it carries `private, no-store` because it can hold an +unpublished record. 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 diff --git a/apps/docs/content/docs/dev/content-engine/publication.mdx b/apps/docs/content/docs/dev/content-engine/publication.mdx index 99f4294cc..96dccbcc1 100644 --- a/apps/docs/content/docs/dev/content-engine/publication.mdx +++ b/apps/docs/content/docs/dev/content-engine/publication.mdx @@ -102,6 +102,19 @@ 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. +### Restoring an old version never moves either column + +With [`editorial`](/docs/dev/content-engine/editorial), an article can be rolled +back to how it read last month. It stays exactly as published as it is right +now. + +That is structural rather than a rule somebody remembered to enforce: `status` +and `publishedAt` are generated columns, and +[`restore`](/docs/dev/content-engine/revisions#it-cannot-change-publication-state) +writes through `schemas.update`, which is a strict object of declared fields. +There is no path through it that can name them. These two routes stay the only +things that move the lifecycle. + ## Permissions Publication adds a fifth permission to the generated set: @@ -282,11 +295,20 @@ Two things hang off the lifecycle, and both are opt-in: Enabling publication on its own still publishes nothing anywhere. -## What this is not +## Publishing later, and going back + +`publish()` on its own means *now*. +[`editorial`](/docs/dev/content-engine/editorial) adds the two things that +change: -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*. +- [Scheduling](/docs/dev/content-engine/scheduling) books a transition for a + time. It runs through this same `publish` / `unpublish`, with the same + idempotency guard - a schedule controls timing, never content. +- [Revisions](/docs/dev/content-engine/revisions) record every transition, and + **restore never moves publication state.** `status` and `publishedAt` are + generated columns, absent from the strict update schema a restore validates + through, so restoring an old draft snapshot onto a live record leaves it live. + That guarantee is structural, not a check somebody remembered to write. +Approval workflows are still not here. [Limitations](/docs/dev/content-engine/limitations) tracks the rest. diff --git a/apps/docs/content/docs/dev/content-engine/revisions.mdx b/apps/docs/content/docs/dev/content-engine/revisions.mdx new file mode 100644 index 000000000..53a38d3d3 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/revisions.mdx @@ -0,0 +1,516 @@ +--- +title: Revisions & locking +description: How a version column stops two editors overwriting each other, what a revision records, and exactly what restore does. +icon: History +--- + +This page is the contract behind +[`editorial: { enabled: true }`](/docs/dev/content-engine/editorial). Two halves, +and they only work together: a lock that refuses a write based on stale data, +and a history that says what the stale data was. + +## Optimistic locking + +No row is held, nothing blocks, and a reader never waits. The write simply +carries the version it believed it was editing: + +```sql +UPDATE "example_articles" +SET "title" = $1, "version" = "version" + 1 +WHERE "id" = $2 AND "version" = $3 +RETURNING ... +``` + +One statement, so two concurrent saves cannot both match. The loser updates zero +rows and gets told why. + +### Gone, or moved? + +Zero rows updated means one of two very different things, and answering "409" to +both would be wrong. So an empty `RETURNING` triggers exactly one narrow +follow-up read - the same idiom `publish` already uses to tell a real transition +from an idempotent one: + +```text +UPDATE ... WHERE id = $1 AND version = $2 → a row? yes → done + → no → SELECT version WHERE id = $1 + → nothing → 404 + → 13 → 409, you sent 12 +``` + +### The envelope + +```jsonc +PUT /api/@vitnode/example/admin/content/example_articles/7 + +{ + "expectedVersion": 12, + "values": { "title": "Hello world" } +} +``` + +`values` is validated by `schemas.update` - a strict object of declared fields. +`expectedVersion` is a sibling, never a member, so it cannot be confused for +content and `version` itself can never be mass-assigned. + +| Route | `expectedVersion` | +| --- | --- | +| `PUT /{id}` | **required** | +| `DELETE /{id}` | **required** | +| `POST /{id}/revisions/{revisionId}/restore` | **required** | +| `POST /{id}/publish`, `/unpublish` | not accepted over HTTP; optional on the service | + +Publishing overwrites no field values. Requiring a version there would fail the +publish button every time a colleague fixed a typo first, in exchange for no +protection at all - the two operations do not contend. The service still accepts +`expectedVersion` on both if your own code wants the stricter guarantee. + +### Deleting needs a version too + +Deleting is the widest overwrite there is, so it takes the same precondition as +an edit - in a body, on a `DELETE`: + +```jsonc +DELETE /api/@vitnode/example/admin/content/example_articles/7 + +{ "expectedVersion": 4 } +``` + +```sql +DELETE FROM example_articles +WHERE id = $1 AND version = $2 +RETURNING … +``` + +Matched nothing? One narrow follow-up read tells the two cases apart, exactly +like an update: **no such row** is a 404, because the caller wanted it gone and +it is; **a different version** is a `CONTENT_VERSION_CONFLICT` 409. Nothing is +deleted and no final revision is written on the conflict path. + +The AdminCP passes the version from the row the person is actually looking at, +so a table left open in a tab cannot remove a record that has moved on since. +When it conflicts, the dialog says the record changed and asks for a refresh - +it does **not** quietly retry with the new version, because a confirmation +dialog cannot describe a change nobody has seen. + + + Its `DELETE /{id}` takes no body and never did. The precondition exists only + where the version column does. + + +### The 409 + +Editorial content types answer conflicts with JSON carrying a machine-readable +`code`, because the AdminCP has to *act* on the difference - one reloads the +record and offers to overwrite, the other points at a field: + +```jsonc +// Someone saved first +{ + "code": "CONTENT_VERSION_CONFLICT", + "contentTypeId": "example.article", + "itemId": 7, + "expectedVersion": 12, + "currentVersion": 13 +} + +// That slug is taken +{ + "code": "CONTENT_UNIQUE_CONFLICT", + "contentTypeId": "example.article", + "itemId": 7 +} +``` + +One discriminated union, so a single OpenAPI schema describes the whole status +and a generated client branches on `code` rather than parsing English. + + + A content type without `editorial` keeps the plain-text 409 it has always + returned. The structured body exists only where the envelope does. + + +Neither body ever contains a constraint name, a column name, a value or a driver +message. Those go to `core_logs`. + +### When the version moves + +| Operation | Version | +| --- | --- | +| create | `1` | +| update, something changed | `+1` | +| update, nothing changed | unchanged - **no write at all** | +| publish or unpublish, real transition | `+1` | +| publish or unpublish, already in that state | unchanged | +| restore that changes at least one field | `+1` | +| restore that changes nothing | unchanged | +| delete | the row is gone; its final revision records `version + 1` | + +The empty-diff case is the pre-existing behaviour, not a new one: `update` has +always loaded the row, diffed it and skipped the write when nothing moved. That +is now also why a pointless save cannot burn a version number. + +## What a revision records + +One row in `core_content_revisions` per **real** mutation, written inside the +same transaction as the write it describes. Six operations: + +```text +create update publish unpublish restore delete +``` + +A unique index on `(contentTypeId, itemId, version)` enforces "at most one +revision per version" in the database, not just in the code - so two racing +captures cannot both land, whatever a future caller does. + +### Nothing writes a revision when nothing happened + +- an update whose diff is empty, +- a publish on something already published, +- an unpublish on something already draft, +- a restore whose values match what is already there. + +These are the same four conditions that already suppress the event and the +search call, so all three stay consistent by construction rather than by three +matching `if`s. + +### The snapshot + +Complete post-mutation state, as JSON. Complete rather than a patch: restoring +from patches means replaying every revision since, which turns one read into a +fold that gets slower as the history grows - and produces nothing at all if one +link was pruned. + +```jsonc +{ + "schemaVersion": 1, + "contentTypeId": "example.article", + "id": 7, + "version": 13, + "createdAt": "2026-08-01T09:00:00.000Z", + "updatedAt": "2026-08-05T10:04:00.000Z", + "publication": { "status": "published", "publishedAt": "2026-08-02T08:00:00.000Z" }, + "fields": { + "title": "Hello world", + "slug": "hello-world", + "excerpt": null, + "views": 42, + "featured": false, + "author": 3, + "category": 1 + } +} +``` + +| Included | Excluded | +| --- | --- | +| Every declared field, slugs included | Relation and user **labels** | +| The publication columns, when the lifecycle is on | Search documents and cache tags | +| `id`, `version`, both timestamps, the content type id | Anything derived | + +Labels are left out on purpose. `admin.titleField` is administrative metadata +belonging to *another* content type, which may not publish it at all - freezing +someone else's private title into your history is not a caching decision to make +casually. A relation is stored as the foreign key it already is, and the AdminCP +resolves the name at display time. + +Dates become ISO strings, `null` is written explicitly, and keys come out +sorted. There is no class instance anywhere in a snapshot, so reading one back +in three years needs nothing but `JSON.parse`. + +`schemaVersion` is in the payload so a future change to that shape is visible +rather than guessed at. + +### Retention + +Pruned in the same transaction that writes the new revision: + +```sql +DELETE FROM "core_content_revisions" +WHERE "contentTypeId" = $1 AND "itemId" = $2 AND "version" <= $3 - $retention +``` + +Versions are strictly increasing and unique per record, so that keeps exactly +the newest `retention` of them and touches an indexed range. + +Doing it inline rather than on a background job is deliberate: an install with +no cron adapter configured must not grow forever, and a queue task would be one +more moving part between "you edited an article" and "the table is bounded". + +Default 50, range 1–500, per content type. A content type with a large +`textarea` should tune it down - 50 revisions of a long article is 50 copies of +that article. + +### Who did it + +```ts +type ContentActorType = "api" | "staff" | "system"; +interface ContentActor { + type: ContentActorType; + userId: null | number; +} +``` + +The **route** decides which one, because only the route knows which gate it sits +behind: a staff session behind the AdminCP permission is `staff`, an +authenticated non-staff caller is `api`, and anything with no user at all is +`system`. + +The **service takes the value object** and never reads it off a request, so it +has no AdminCP dependency and your own code can call it from a queue handler, a +CLI script or a migration. + +`system` exists so that nothing anywhere has to invent a fake user id. A +`userId` in a revision is a real person or it is `null`. + +## Restore + +```jsonc +POST /api/@vitnode/example/admin/content/example_articles/7/revisions/104/restore + +{ "expectedVersion": 13 } +``` + +Requires `can_restore`. In order: + +1. **Load revision 104 scoped by `(pluginId, contentTypeId, itemId)`.** A + revision id alone is never trusted - the table is shared by every editorial + content type in the install. Not found, or found but belonging to another + record → **404**. +2. **Project** the snapshot onto the content type's *current* field list. +3. **Validate** through today's `schemas.update`. +4. **Diff** against the live row. Nothing to change → `changed: false`, 200, no + write, no version, no revision. +5. **Conditional UPDATE**, guarded by `expectedVersion`. +6. **Capture** a `restore` revision with `restoredFromRevisionId: 104`. + +### It creates history, it never rewrites it + +- The record becomes version **14**, not version 4 again. +- Versions 5 through 13 stay in the history. Nothing is deleted. +- The restore is itself a revision, so "we went back to v4" is in the log too. + +### It cannot change publication state + +Restoring a snapshot taken while the article was a draft onto a currently +published article leaves it **published**. + +That is structural, not a check somebody remembered to write: `status` and +`publishedAt` are generated columns, absent from `schemas.update`, which is a +strict object. There is no path through restore that can name them. Publish and +unpublish remain the only two things that move the lifecycle. + +### When the content type has moved on + +A snapshot from six months ago describes a content type that may no longer +exist in that shape. Every case is defined, and every failure is a full +rollback: + +| The snapshot | What happens | +| --- | --- | +| has a field that was since removed | Dropped by the projection. Not an error | +| is missing a field added since | Not restored; the current value stays. The row already holds something legal | +| holds a value today's rules reject - `maxLength` shrank, an enum value went away | **422** `CONTENT_REVISION_NOT_RESTORABLE`, listing the field names | +| points at a relation that has been deleted | **400**, the existing foreign-key mapping | +| would put back a slug another row now owns | **409** `CONTENT_UNIQUE_CONFLICT` | +| is fine, but the record moved while you were reading | **409** `CONTENT_VERSION_CONFLICT` | + +```jsonc +// 422 +{ + "code": "CONTENT_REVISION_NOT_RESTORABLE", + "contentTypeId": "example.article", + "revisionId": 104, + "fields": ["title"] +} +``` + +Field names only - never a Zod issue tree, which names internal paths. + + + Every failure above rolls the whole transaction back. A restore either applies + completely or changes nothing, so a half-restored record cannot exist. + + +### What it emits + +One event, `content.{id}.restored`, and nothing else - not a `restored` plus an +`updated`. A generated route emits exactly one event per mutation, and doubling +that would double every downstream listener's work. `restored` carries +`changedFields`, so porting an `updated` listener is a one-line change. + +Search and cache follow the same rules an update does, because a restore *is* an +update as far as public visibility is concerned: + +| The record | Search | Cache | +| --- | --- | --- | +| draft | nothing - there is no document | nothing; private before and after | +| published, no indexed field moved | nothing | stale-while-revalidate | +| published, an indexed field moved | one upsert | stale-while-revalidate | +| published, the slug moved | one upsert, new URL | **immediate**, on the old *and* new slug tags | +| unchanged | nothing | nothing | + +## Reading history + +```text +GET /{id}/revisions can_view metadata, newest first, paginated +GET /{id}/revisions/{revisionId} can_view one revision, with its snapshot +POST /{id}/revisions/{id}/restore can_restore +``` + +The list is metadata only - version, operation, actor, timestamp and +`changedFields`. Opening the history of a long-lived article must not drag every +historical copy of its body across the wire, so the snapshot is a second request +for the one revision you actually expanded. + +Author names come from a `LEFT JOIN` in the list query, so a 25-row history is +still one round trip to the database. + +### Paging through it + +```jsonc +// GET /{id}/revisions?first=25&cursor=36 +{ + "edges": [ /* … */ ], + "pageInfo": { "endCursor": 12, "hasNextPage": true } +} +``` + +| | | +| --- | --- | +| `cursor` | The **version** of the last row you already have. Exclusive: the next page starts strictly below it, so no row is ever returned twice | +| `first` | 1–100, defaulting to 25. Outside that range is a 400, not a silent clamp | +| `endCursor` | The last version on this page. Pass it straight back as `cursor` | +| `hasNextPage` | Read from one extra row, not a `COUNT` - so it cannot disagree with the rows beside it | + +The cursor is a version rather than an offset or a revision id, and that is what +makes paging stable: versions are unique per record and strictly decreasing down +the list, so a revision written between two requests is *newer* than the cursor +and page two returns exactly what it would have returned before. A pruned +revision leaves no gap in the sequence either, because the cursor is a bound and +not a position. + + + Retention defaults to 50 and a page to 25, so a single page was never the whole + history. The AdminCP has a **Load older versions** button that appends, and the + loop terminates when `hasNextPage` is false. + + +## In the AdminCP + +### The conflict + +The edit dialog **stays open and keeps everything you typed.** A banner appears +above the fields saying the record moved to version 13 while you were editing. + +- **Show what changed** loads the current record and lists only the fields + another session actually moved, old value struck through. +- Saving again is a second, deliberate click, and it posts the *new* version. + +Nothing merges automatically. Deciding which half of a rewritten paragraph +survives is an editorial judgement, and guessing at it is worse than asking. + +### The history dialog + +A clock icon on every row, lazy-loaded like the edit form. One line per version +with the operation as a badge, the author (or **System**), a localised date and +the changed fields. Expanding one renders a field-level diff against the version +before it - a badge for an enum, a formatted date for a `dateTime`, long +`textarea` values collapsed, `null` as the same em-dash the table cells use, and +no raw JSON anywhere. A relation shows the id the snapshot stores; see +[the AdminCP page](/docs/dev/content-engine/admincp#history). + +Restore asks for confirmation and states all four facts: which version, that it +creates a new one, that nothing in between is deleted, and that the publication +state does not change. + +Afterwards the dialog stays open and does three things: reloads the first page +so the restore's *own* revision appears, refreshes the table behind it, and +adopts the new current version - so a second restore in the same sitting posts +the right precondition instead of conflicting with the first. + +## Calling the service directly + +`model.editorialService` is `undefined` unless the content type has +`editorial` - exactly like +[`publicService`](/docs/dev/content-engine/public-service), and for the same +reason. The optional call reads naturally in code that does not know which +content type it was handed, and the compiler makes you check. + +The plugin id arrives at call time rather than being baked into the model: a +revision is stamped with its owner, and `createContentModel` lives in +`src/database/*.ts`, which has no other reason to know it. + +```ts +const editorial = articleContent.editorialService?.(c, { + pluginId: "@vitnode/example", +}); +if (!editorial) throw new HTTPException(404); + +const outcome = await editorial.update( + 7, + { title: "Hello world" }, + { actor: { type: "system", userId: null }, expectedVersion: 12 }, +); +if (!outcome) throw new HTTPException(404); + +outcome.changed; // false ⇒ nothing was written, and nothing needs announcing +outcome.version; // 13 +outcome.revisionId; // 512, or null on a no-op +outcome.previousSlug; // the URL that just stopped resolving +``` + +Each method opens its own transaction when you do not pass one, and joins yours +when you do. The content write, the version increment and the revision insert +are always in the same transaction - and **nothing else is**. No event, no +search call, no cache API, no HTTP: a rolled-back transaction cannot un-send +those. + +Post-commit is one helper, so the routes and your own code make the same +decisions: + +```ts +import { contentEditorialEffects } from "@vitnode/core/content/server"; + +const { event, search } = await contentEditorialEffects( + c, + articleContentType, + outcome, + { pluginId }, +); +// emits the right event and syncs search - or returns `{ event: null, +// search: null }` when `outcome.changed` is false +``` + +Neither throws: `event.failures` and `search.error` report what went wrong so +the caller can decide whether it is worth retrying. `pluginId` owns both the +search document and the event envelope. + +Cache invalidation stays where it has to: in a server action, after the commit. +See [Caching](/docs/dev/content-engine/caching#writing-who-expires-what). + +## Security + +Every revision query - read, restore and prune alike - filters on **all three** +of `pluginId`, `contentTypeId` and `itemId`. Not one of them is optional +anywhere, which is the point: `core_content_revisions` is shared by every +editorial content type in the install, so an id on its own proves nothing about +ownership. + +That single rule closes the whole family at once: reading another plugin's +history, restoring another content type's revision, and restoring a revision +that belongs to a different record of the same content type. + +There is no foreign key to the content row, for the same reason +`core_search_index` has none - content tables are generated from a runtime +descriptor and core's static schema cannot name them. Deleting a record +therefore leaves its history in place, which is what an audit trail is for. +`serial` ids are not reused in normal operation, and the mandatory scoping means +that even a manual `setval` cannot attach one record's history to another. + +The database unique key is `(contentTypeId, itemId, version)` with no +`pluginId`, and that is sufficient rather than an oversight: content type ids +are validated for uniqueness across **every installed plugin** at boot, so an id +already identifies exactly one content type and one table. `pluginId` is still a +column, because ownership is what the cleanup job keys off when a plugin is +removed. diff --git a/apps/docs/content/docs/dev/content-engine/scheduling.mdx b/apps/docs/content/docs/dev/content-engine/scheduling.mdx new file mode 100644 index 000000000..e64278cd1 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/scheduling.mdx @@ -0,0 +1,397 @@ +--- +title: Scheduled publishing +description: Book a publication for 9am on Monday, and have it happen without anyone being awake for it. +icon: CalendarClock +--- + +`publish()` means now. This is the block that adds "and also, later". + +```ts title="src/content/article.ts" +editorial: { + enabled: true, + scheduling: { enabled: true }, +} +``` + + + A schedule moves `status`, so without + [`publication`](/docs/dev/content-engine/publication) there is no status to + move. Turning it on without one is a compile error, and a boot error if you + cast past it. + + +## What it does + +A calendar action on every row, three routes, and one background task shared by +every schedulable content type in the install. + +```text +GET /{id}/schedules can_view +POST /{id}/schedule can_publish +POST /{id}/schedule/{scheduleId}/cancel can_publish +``` + +`can_publish`, not `can_edit`: booking a publication *is* publishing, just +later. A role trusted to write drafts is not automatically trusted to put one on +the internet at 9am on Monday. + +```jsonc +// POST /{id}/schedule +{ "action": "publish", "scheduledFor": "2026-08-10T09:00:00.000Z" } +``` + +## The rules + +| Rule | Why | +| --- | --- | +| One pending schedule per record **and action** | Enforced by a partial unique index, so two requests arriving together cannot both insert | +| A publish and an unpublish may both be pending | "Live on Monday, gone on Friday" is a normal thing to want | +| An unpublish must be strictly after a pending publish | Otherwise it fires against a draft, does nothing, and the record goes live afterwards - the opposite of what was asked | +| A time up to two minutes past is accepted | A browser clock a minute behind the server is ordinary, and "now" is what the editor meant | +| Anything earlier is a **400** | With a `code`, so the dialog points at the date field rather than raising a general error | + +Both the dialog and the route run the same function, +`contentScheduleTimingError`, so a date is refused where the editor is looking +*and* the server stays the authority. They cannot drift into disagreeing. + +## Precision is ±60 seconds + +The queue drains on the per-minute `process-queue` cron, so a schedule fires +within about a minute of its time. The UI says so and this page says so; nothing +rounds it away quietly. + + + Schedules are saved and simply never run without an in-process scheduler. The + dialog checks and warns - the flag rides on the schedules route rather than + the debug endpoint, which needs a permission an editor may not have. See + [Cron](/docs/dev/cron). + + +## It publishes what the record says *then* + +A schedule controls **timing**, not content. Editing an article after booking it +changes what goes live. That is usually what people want and occasionally a +surprise, so the dialog says it out loud. + +Freezing a reviewed version for release is an approval workflow, which is +[deliberately out of scope](/docs/dev/content-engine/limitations). + +## Rescheduling, cancelling, and stale tasks + +This is the interesting part, because a queued task cannot be un-queued. + +Booking a schedule inserts a row **and** its queue task in one transaction, so a +schedule can never exist without the thing that executes it. Rescheduling +cancels the old row and inserts a new one with `generation + 1`. + +The old queue task is left exactly where it is. When it wakes up it re-reads the +row under a lock and finds four things it needs to be true: + +```text +the row still exists +status === "pending" +generation === the one the task was dispatched with +scheduledFor <= now +``` + +Any mismatch and it does nothing at all. That is far more reliable than hunting +down and deleting a queue row, and it is the same shape as every other no-op in +the engine. + + + A task carries `{ scheduleId, generation }` and nothing else. Every real value + is re-read from the row under `FOR UPDATE`. A payload carrying the action + would go stale the moment somebody rescheduled, and one carrying the item id + would be a way to publish an arbitrary record by inserting a queue row. + + +### Cancelling is guaranteed only *before* the claim + +The lock the task takes is held from the claim right through to the commit - +the transition, its revision, the settlement and the follow-up task are one +transaction. That gives cancellation a precise meaning: + +| When the cancel arrives | What happens | +| --- | --- | +| Before the task claims the row | The cancel wins. The task wakes, finds `status = 'cancelled'`, and does nothing | +| While the task holds the lock | The cancel **waits** on the row, then re-checks its `status = 'pending'` condition, finds `completed`, and answers **404 Schedule not found** | +| After the task committed | The same 404, immediately | + +So a successful cancel really means cancelled, and a 404 on a pending-looking +schedule means it ran while you were clicking. There is no third outcome where +the request says yes and the article goes live anyway. + +The settlement is guarded the same way - `SET status = 'completed' WHERE id = $id +AND status = 'pending'` - so a worker that somehow lost its lock cannot rewrite +a cancelled plan into one that ran. If that write matches nothing, the whole +transition rolls back rather than publishing something nobody wanted. + +Rescheduling is a cancel and an insert in one transaction, so it follows exactly +the same rules. + +## Idempotency comes for free + +Nothing extra guards against a double run, because the existing transition guard +already does: + +- `publish` is `WHERE status <> 'published'`, so a second run changes nothing, +- **no change** means no version bump, no revision, no event, no search write + and no cache invalidation, +- and the schedule is marked `completed` in the same transaction, so the third + run does not get that far. + +A retry, a duplicate task and a cancelled plan are all harmless, and each one is +covered by a test that asserts search and the cache were **not** touched. + +## What happens when one fires + +Exactly what happens when somebody clicks the button, through the same helper: + +| | | +| --- | --- | +| Version | `+1`, like any real transition | +| Revision | one `publish` or `unpublish`, with `actorType: "system"` | +| Event | the ordinary `published` / `unpublished`, with `scheduledBy` set | +| Search | one upsert, or one delete | +| Cache | the right tags, expired over the [bridge](#the-cache-bridge) | + +Scheduling itself is **not** a revision and consumes no version - it changes no +field value. It emits `scheduled` and `schedule_cancelled` instead, which are +new events on schedulable content types only. + +### Two tasks, not one + +The transition and the announcements are separate units of work, because they +fail differently. Publishing is a database write that either committed or did +not. Telling everyone else - an event, a search document, an HTTP hop to the web +app - is three calls to systems a transaction cannot reach, any of which can be +down for a minute. + +```text +content-schedule claim → publish → revision → settle → enqueue effects + ── one transaction ────────────────────────────────── +content-schedule-effects event → search → cache bridge +``` + +The `content-schedule-effects` row is written **inside** the transition's +transaction, so it exists if and only if the transition committed. A crash a +millisecond after the commit loses nothing: the row is durable and the queue +drains it on the next tick. + + + Retrying them together would re-run the publish - which is idempotent, so the + second run finds nothing changed and skips the announcements **entirely**. + That is exactly how a scheduled unpublish ends up permanently serving a cached + page it should have expired, and splitting the two is what removes it. + + +The effects task carries everything it needs, frozen at commit time: the row as +the transition returned it, the revision id, the operation, and who booked the +schedule. It never re-reads the record, and it never publishes anything - so a +record edited in the meantime cannot turn one announcement into a different one. + +### All three have to land + +The task succeeds only when every effect was accepted. Each of the three has a +way of failing quietly, and the whole point of a durable task is that none of +them gets to: + +| Effect | Counts as delivered when | Retried when | +| --- | --- | --- | +| Event | Every listener ran | `EventEmitResult.failures` is non-empty - a listener threw, or the broker was unreachable | +| Search | The engine accepted the write | It threw | +| Cache | **Every** configured web origin accepted the invalidation | Any one of them did not | + + + With two web apps behind one API, `1/2` origins accepting a scheduled + unpublish means one of them is still serving a page that was withdrawn. Older + behaviour treated any delivery as success and never tried the other again; + now `delivered < attempted` retries the task. `attempted: 0` is different - + it means there was no tag to expire or no origin configured, which is a + decision rather than an outage. + + +`EventsModel.emit()` is the subtle one. It deliberately **never throws**: an +interactive mutation has already committed by the time listeners run, and a +broken notification listener is not a reason to tell somebody their save +failed. So it reports instead, in `EventEmitResult.failures` - and a caller +that only awaits it has quietly agreed the event may go missing. The +scheduled-effects task reads that result and treats a failure as retryable. +Nothing about interactive routes changes. + +### Delivery is at-least-once + +| Effect | On a retry | +| --- | --- | +| Search | An upsert or a delete, idempotent by construction | +| Cache | Expiring a tag again is the same operation | +| Event | **Delivered again.** A listener that must act once needs its own idempotency key - `scheduleId` and `revisionId` are both in the payload and both stable | + +A retry re-runs all three, including the ones that already worked - there is no +per-effect bookkeeping, and adding one would be an outbox by another name. So a +listener whose work must happen exactly once keys off `scheduleId`: + +```ts +buildEventListener({ + event: "content.example.article.published", + name: "announce-once", + handler: async (c, payload) => { + if (payload.scheduleId && (await alreadyAnnounced(payload.scheduleId))) { + return; + } + + await announce(payload.contentId); + }, +}); +``` + +`scheduleId` is present only on a scheduled transition; an interactive publish +is emitted once by the route that performed it and has no booking to point at. + +There is no outbox and no exactly-once claim. Five attempts with the queue's +ordinary backoff, then the row is `failed` and visible in +**Core → Advanced → Queue Tasks**. + +### An effect failure is not a publication failure + +The schedule stays `completed`, because the publication really did happen. +What failed is recorded separately, in `effectsError` on the schedule row, and +the AdminCP shows it as *"Published, but the announcements did not go out yet"* +rather than as a failed publish. A schedule is never moved back to `pending` +because an announcement bounced. + +When more than one effect failed, `effectsError` holds all of them, because +fixing the first is not much use if the second is still down: + +```text +event: @vitnode/example:notifications:send-notification (Service unavailable); search: Elasticsearch unavailable; cache: 1/2 web origins accepted the invalidation +``` + +The run that finally gets all three through clears it. + +### Nobody is impersonated + +A scheduled run's actor is `{ type: "system", userId: null }`, because that is +the truth. Who *asked* for it is on the schedule row and travels in the event as +`scheduledBy`, so "the system did it, on Anna's instruction" is fully +recoverable without inventing a user id anywhere. + +The envelope's **`pluginId` is a separate question**, and it has a separate +answer. Core owns the queue handler, so `c.get("plugin")` says `@vitnode/core` +while the task runs - but `content.example.article.published` is the example +plugin's event however it was triggered. The Content Engine passes the owner +explicitly on every emit, interactive and scheduled alike, so the envelope +reads: + +```text +queue task owner @vitnode/core ← who runs the handler +event envelope @vitnode/example ← who owns the content type +``` + +Nothing swaps `c.get("plugin")` for the duration. That context is shared with +the logger, the permission checks and every other model on the request; +impersonating a plugin inside it would change all of them to fix one field. + +## The cache bridge + +Here is the awkward part, stated plainly: **the queue does not run in Next.** In +a split deployment it is a plain `@hono/node-server` process where importing +`next/cache` throws, and even inside a single Next app it is a Route Handler, +where `updateTag` is unavailable. + +So a scheduled publish cannot expire a cache tag by calling a function. It asks +the process that can: + +```text +queue handler (API) + → POST {webOrigin}/api/vitnode/content/revalidate + Authorization: Bearer CRON_SECRET + x-vitnode-timestamp: + → Route Handler (web) + → revalidateTag(tag, { expire: 0 }) +``` + +Mount the handler once per web app - it is in the scaffold already: + +```ts title="src/app/api/vitnode/content/revalidate/route.ts" +export { POST } from "@vitnode/core/content/next/revalidate-route"; +``` + +| Concern | Answer | +| --- | --- | +| Auth | `Bearer CRON_SECRET`, compared with `timingSafeEqual`. Already documented as the secret "for internal API calls", already flagged when insecure | +| Replay | A `±5 minute` timestamp window. Replaying a revalidation only expires a tag again, so a nonce store would be a table guarding nothing | +| Which origin | `NEXT_PUBLIC_WEB_URL` by default; `buildApiConfig({ content: { revalidateOrigins: [...] } })` for several front ends, each posted independently | +| Failure | Two attempts inside the bridge, then the **effects task** retries the whole delivery on the queue's backoff | + + + The bridge itself is best effort and never throws - but the task that calls it + fails unless **every** origin accepted the request, so the queue retries it. + Because that task never republishes, a web app that was redeploying for two + minutes gets its tags expired on the next attempt instead of never. That is + the whole reason the effects are a task of their own. + +A retry re-posts to every origin, including the ones that already succeeded. +Expiring an already-expired tag is a no-op, so that is cheaper than tracking +which of them worked. + + + +### `immediate` from a Route Handler + +`updateTag` is Server-Action-only - that restriction is what buys +read-your-own-writes. `revalidateTag(tag, { expire: 0 })` is the documented +webhook equivalent, so `revalidateContent` takes a `context` and picks the +strongest option available where it is called: + +```ts +revalidateContent(input, { context: "route-handler", mode: "immediate" }); +``` + +| context | mode | Calls | +| --- | --- | --- | +| `server-action` (default) | `immediate` | `updateTag(tag)` | +| `route-handler` | `immediate` | `revalidateTag(tag, { expire: 0 })` | +| either | `stale-while-revalidate` | `revalidateTag(tag, "max")` | + +Nothing about the existing Server Action paths changed. + +## The AdminCP + +A calendar icon leads the actions cell for a schedulable content type. The +dialog shows what is booked, what already ran and who booked it, plus a form +with two fields: what should happen, and when. + +- The date field says which timezone it is reading, because "9am" is a question + otherwise. +- A pending schedule can be cancelled from the same list - up until the moment + the worker claims it, after which the cancel answers 404 because the schedule + already ran. +- A pending schedule whose time has passed is marked **overdue**, with the last + error if there was one - that is the shape a failed run takes, since there is + no `failed` status. +- A completed schedule whose announcements have not landed says so, in a + different colour and different words: the record *is* published. +- Without a cron adapter, a warning sits above everything. + +## Retention + +Completed and cancelled rows are **kept**. "Who scheduled this, and when did it +go out" is the audit trail the feature exists to provide; deleting it the moment +it succeeds would answer that question with silence. + +A daily core cron (`content-editorial-cleanup`) removes settled rows past 30 +days, and rows whose content type is no longer registered at all. + +## What it does not do + +| Not supported | Why | +| --- | --- | +| To-the-second precision | The queue drains on a one-minute tick | +| Scheduling a **field edit** | Only `status` moves. A content change is an edit, and edits are immediate | +| Freezing what goes live | The schedule publishes the record as it stands at that moment | +| Recurring schedules | One row, one time, one action | +| A `failed` status | An overdue `pending` row with `lastError` says the same thing with one fewer state that can be wrong | +| Exactly-once events | Effects are retried as a unit, so a listener can see one `published` twice. Key off `scheduleId` or `revisionId` if that matters | +| Cancelling a schedule that is mid-flight | The lock is held to the commit, so the cancel waits and then honestly reports that it ran | +| Firing without a cron adapter | Nothing drains the queue. The UI warns | diff --git a/apps/docs/content/docs/dev/content-engine/schemas.mdx b/apps/docs/content/docs/dev/content-engine/schemas.mdx index d8b65edcd..d4a51293e 100644 --- a/apps/docs/content/docs/dev/content-engine/schemas.mdx +++ b/apps/docs/content/docs/dev/content-engine/schemas.mdx @@ -75,9 +75,16 @@ Describes the response, including `id`, `createdAt` and `updatedAt`. Dates are 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. +`status` and `publishedAt`, and with +[editorial](/docs/dev/content-engine/editorial) it also carries `version`. All +three are absent from `create` and `update`, which are strict - so publishing is +only ever reachable through `service.publish` and its route, and `version` +belongs to the engine alone. + +That strictness is also what makes the +[update envelope](/docs/dev/content-engine/revisions#the-envelope) look the way +it does: `expectedVersion` travels *beside* `values`, never inside it, because +`update` would reject it. ## publicSelect diff --git a/apps/docs/content/docs/dev/content-engine/service.mdx b/apps/docs/content/docs/dev/content-engine/service.mdx index 89e8bf29a..621ba6acd 100644 --- a/apps/docs/content/docs/dev/content-engine/service.mdx +++ b/apps/docs/content/docs/dev/content-engine/service.mdx @@ -253,6 +253,31 @@ 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. +## The editorial service is a different object + +A content type with [`editorial`](/docs/dev/content-engine/editorial) also +exposes `model.editorialService`, and the generated routes use **that** one for +every write. It is not a wrapper you can ignore: it opens a transaction, guards +the write with `expectedVersion`, bumps `version` and captures a revision, all in +one commit. + +```ts +const editorial = articleContent.editorialService?.(c, { pluginId }); +if (!editorial) throw new HTTPException(404); + +const outcome = await editorial.update( + 7, + { title: "Updated" }, + { actor: { type: "staff", userId: 1 }, expectedVersion: 12 }, +); +``` + +`model.service(c)` still works on an editorial content type and still writes +rows - but it does not lock, does not bump the version and records no history. +That is the right tool for a migration or a backfill, and the wrong one for +anything a person did. Full contract in +[Revisions and locking](/docs/dev/content-engine/revisions#calling-the-service-directly). + ## 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 095f08b22..6caa27d6a 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -250,6 +250,16 @@ content.example.article.published content.example.article.unpublished ``` +And one that opts into +[`editorial`](/docs/dev/content-engine/editorial) emits one more, plus two with +[`editorial.scheduling`](/docs/dev/content-engine/scheduling): + +```text +content.example.article.restored +content.example.article.scheduled +content.example.article.schedule_cancelled +``` + 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. @@ -270,21 +280,129 @@ core event - `changedFields` narrows to that content type's own field names. "Published only - when the row first went live. Never rewritten by a later unpublish/republish.", type: "Date", }, + version: { + description: + "Restored only - the version the record holds after the restore. Always a new number; the restored version is never reinstated.", + type: "number", + }, + revisionId: { + description: "Restored only - the revision this restore itself created.", + type: "number", + }, + restoredFromRevisionId: { + description: "Restored only - the revision the values were taken from.", + type: "number", + }, + scheduleId: { + description: + "The schedule row this is about. Always present on scheduled and schedule_cancelled; present on published and unpublished only when a schedule fired them, where it doubles as the idempotency key for at-least-once retries.", + type: "number", + }, + scheduledFor: { + description: "Scheduled only - when the transition will happen.", + type: "Date", + }, + action: { + description: + "Scheduled and schedule_cancelled only - which transition was booked.", + type: '"publish" | "unpublish"', + }, + scheduledBy: { + description: + "Published and unpublished only, and only when a schedule fired them - the person who booked it. Absent on an interactive publish.", + type: "null | number", + }, }} /> -The envelope already carries the actor, the emitting plugin and the timestamp, -so the payloads stay minimal. +Booking a schedule changes no field value, so it consumes no version and writes +no revision - `scheduled` is a different thing from `published`. When the +schedule fires, the resulting transition emits the ordinary `published` or +`unpublished` with `scheduledBy` and `scheduleId` set. + +### A scheduled event may arrive twice + +Announcements for a scheduled transition run in a +[durable queue task](/docs/dev/content-engine/scheduling#all-three-have-to-land) +that retries whenever the event, the search write or a cache origin failed - and +a retry re-emits an event that some listeners already received. Delivery is +**at-least-once**, deliberately: the alternative is a transactional outbox, +which Stage 4 does not have. + +A listener whose work must happen exactly once keys off `scheduleId`, which is +stable across every attempt at the same booking: + +```ts +handler: async (c, payload) => { + if (payload.scheduleId && (await alreadyDone(payload.scheduleId))) return; + + await sendTheAnnouncement(payload.contentId); +}; +``` + +An interactive publish is emitted once, by the route that performed it, and +carries no `scheduleId`. + +### The envelope's owner is the content type's plugin + +`pluginId` on the envelope answers "whose event is this", not "who was running +at the time". Those come apart the moment something happens on a schedule: core +owns the queue handler, so `c.get("plugin")` says `@vitnode/core`, while +`content.example.article.published` belongs to the example plugin as much as it +ever did. + +```text +queue task owner @vitnode/core ← who runs the handler +event envelope @vitnode/example ← who owns the content type +``` + +The Content Engine passes the owner explicitly on every emit, so ownership does +not depend on which route module or queue handler invoked it. Your own code can +do the same when it emits on someone else's behalf: + +```ts +await c.get("events").emit("blog.post.created", payload, { + pluginId: "@vitnode/blog", +}); +``` + +Omit the option and nothing changes: the envelope falls back to +`c.get("plugin")` and then to `@vitnode/core`, exactly as before. Pass it +rather than swapping `c.get("plugin")` - that context is shared with the +logger, the permission checks and every other model on the request. + +The envelope also carries the actor and the timestamp, so the payloads stay +minimal. + +### Failures are reported, not thrown + +`emit()` never throws. Listeners run after the write it describes has +committed, and a broken listener is not a reason to tell somebody their save +failed - so a failure comes back in the result instead: + +```ts +const result = await c.get("events").emit("blog.post.created", payload); + +result.delivered; // listeners that ran +result.failures; // [{ pluginId, module, listener, error }] +``` + +Interactive routes ignore that result on purpose, because the mutation +succeeded either way. Background work usually should not: the scheduled-effects +task inspects `failures` and retries the whole delivery when it is non-empty. 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 +update, a no-op publish and a restore that changes nothing all emit nothing, and +`published`, `unpublished` and `restored` 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). +`restored` carries `changedFields` exactly like `updated`, so a listener written +for one ports to the other in a line - which is why a restore does not emit both. + **Use cases:** reindex the row for search, invalidate a CDN entry, or mirror the change into a plugin-owned projection. See [Generated events](/docs/dev/content-engine/events). diff --git a/apps/docs/migrations/0025_add_content_revisions.sql b/apps/docs/migrations/0025_add_content_revisions.sql new file mode 100644 index 000000000..973e967a3 --- /dev/null +++ b/apps/docs/migrations/0025_add_content_revisions.sql @@ -0,0 +1,20 @@ +CREATE TABLE "core_content_revisions" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "version" integer NOT NULL, + "operation" varchar(20) NOT NULL, + "snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL, + "changedFields" jsonb DEFAULT '[]'::jsonb NOT NULL, + "actorType" varchar(16) DEFAULT 'system' NOT NULL, + "actorUserId" integer, + "restoredFromRevisionId" integer, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_content_revisions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_content_revisions" ADD CONSTRAINT "core_content_revisions_actorUserId_core_users_id_fk" FOREIGN KEY ("actorUserId") REFERENCES "public"."core_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_revisions_item_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","version");--> statement-breakpoint +CREATE INDEX "core_content_revisions_plugin_id_idx" ON "core_content_revisions" USING btree ("pluginId");--> statement-breakpoint +CREATE INDEX "core_content_revisions_actor_user_id_idx" ON "core_content_revisions" USING btree ("actorUserId"); \ No newline at end of file diff --git a/apps/docs/migrations/0026_add_example_article_editorial.sql b/apps/docs/migrations/0026_add_example_article_editorial.sql new file mode 100644 index 000000000..99ae91f24 --- /dev/null +++ b/apps/docs/migrations/0026_add_example_article_editorial.sql @@ -0,0 +1 @@ +ALTER TABLE "example_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL; \ No newline at end of file diff --git a/apps/docs/migrations/0027_add_content_schedules.sql b/apps/docs/migrations/0027_add_content_schedules.sql new file mode 100644 index 000000000..5fb3475f1 --- /dev/null +++ b/apps/docs/migrations/0027_add_content_schedules.sql @@ -0,0 +1,23 @@ +CREATE TABLE "core_content_schedules" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "action" varchar(16) NOT NULL, + "scheduledFor" timestamp NOT NULL, + "generation" integer DEFAULT 1 NOT NULL, + "status" varchar(16) DEFAULT 'pending' NOT NULL, + "createdBy" integer, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "completedAt" timestamp, + "lastError" text +); +--> statement-breakpoint +ALTER TABLE "core_content_schedules" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_content_schedules" ADD CONSTRAINT "core_content_schedules_createdBy_core_users_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."core_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_schedules_active_unique" ON "core_content_schedules" USING btree ("contentTypeId","itemId","action") WHERE status = 'pending';--> statement-breakpoint +CREATE INDEX "core_content_schedules_due_idx" ON "core_content_schedules" USING btree ("status","scheduledFor");--> statement-breakpoint +CREATE INDEX "core_content_schedules_item_idx" ON "core_content_schedules" USING btree ("contentTypeId","itemId");--> statement-breakpoint +CREATE INDEX "core_content_schedules_plugin_id_idx" ON "core_content_schedules" USING btree ("pluginId");--> statement-breakpoint +CREATE INDEX "core_content_schedules_created_by_idx" ON "core_content_schedules" USING btree ("createdBy"); \ No newline at end of file diff --git a/apps/docs/migrations/0028_add_content_schedule_effects_error.sql b/apps/docs/migrations/0028_add_content_schedule_effects_error.sql new file mode 100644 index 000000000..f53e168a7 --- /dev/null +++ b/apps/docs/migrations/0028_add_content_schedule_effects_error.sql @@ -0,0 +1 @@ +ALTER TABLE "core_content_schedules" ADD COLUMN "effectsError" text; \ No newline at end of file diff --git a/apps/docs/migrations/meta/0025_snapshot.json b/apps/docs/migrations/meta/0025_snapshot.json new file mode 100644 index 000000000..ba89f1462 --- /dev/null +++ b/apps/docs/migrations/meta/0025_snapshot.json @@ -0,0 +1,2696 @@ +{ + "id": "4f1c20aa-86f5-4d5b-9b4f-75101d57d8de", + "prevId": "f73d7a47-f42f-4629-8af1-b388c220a427", + "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_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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/0026_snapshot.json b/apps/docs/migrations/meta/0026_snapshot.json new file mode 100644 index 000000000..1890e22be --- /dev/null +++ b/apps/docs/migrations/meta/0026_snapshot.json @@ -0,0 +1,2703 @@ +{ + "id": "410ddb95-6db7-4923-96be-141fc9454cb6", + "prevId": "4f1c20aa-86f5-4d5b-9b4f-75101d57d8de", + "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_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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/0027_snapshot.json b/apps/docs/migrations/meta/0027_snapshot.json new file mode 100644 index 000000000..0f7a264b8 --- /dev/null +++ b/apps/docs/migrations/meta/0027_snapshot.json @@ -0,0 +1,2913 @@ +{ + "id": "949a0b2c-84b1-43ba-83fa-2e2ab286905c", + "prevId": "410ddb95-6db7-4923-96be-141fc9454cb6", + "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_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "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()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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/0028_snapshot.json b/apps/docs/migrations/meta/0028_snapshot.json new file mode 100644 index 000000000..7ccb924a1 --- /dev/null +++ b/apps/docs/migrations/meta/0028_snapshot.json @@ -0,0 +1,2919 @@ +{ + "id": "ad292f66-b888-469c-84fc-5b2fb5dd0dcd", + "prevId": "949a0b2c-84b1-43ba-83fa-2e2ab286905c", + "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_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "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()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 74d9a35a7..b9fbeb52a 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -176,6 +176,34 @@ "when": 1785764469085, "tag": "0024_add_example_article_slug", "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1785955595563, + "tag": "0025_add_content_revisions", + "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1785957233989, + "tag": "0026_add_example_article_editorial", + "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1786018313984, + "tag": "0027_add_content_schedules", + "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1786024625069, + "tag": "0028_add_content_schedule_effects_error", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/docs/src/app/api/vitnode/content/revalidate/route.ts b/apps/docs/src/app/api/vitnode/content/revalidate/route.ts new file mode 100644 index 000000000..7494f6bc1 --- /dev/null +++ b/apps/docs/src/app/api/vitnode/content/revalidate/route.ts @@ -0,0 +1,12 @@ +/** + * The web-side half of the Content Engine's background cache bridge. + * + * Needed because the API process cannot call `next/cache`: in a split + * deployment it is plain Node, and even inside this app the queue runs in a + * Route Handler where `updateTag` is unavailable. When a scheduled publish + * makes a record public, this is what expires the tags. + * + * Authorized with `CRON_SECRET` and a timestamp window. The worst a valid + * request can do is expire a cache tag. + */ +export { POST } from "@vitnode/core/content/next/revalidate-route"; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts new file mode 100644 index 000000000..2d534250f --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts @@ -0,0 +1,12 @@ +/** + * The web-side half of the Content Engine's background cache bridge. + * + * Needed because the API process cannot call `next/cache`. When a scheduled + * publish makes a record public, this is what expires the tags so the page goes + * live without waiting for the cache to age out. + * + * Authorized with `CRON_SECRET` and a timestamp window. The worst a valid + * request can do is expire a cache tag. Delete this file only if you never use + * [scheduled publishing](https://vitnode.com/docs/dev/content-engine/scheduling). + */ +export { POST } from "@vitnode/core/content/next/revalidate-route"; diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 1e6ffb954..a2dfe6a95 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -96,6 +96,11 @@ "types": "./dist/src/content/next/index.d.ts", "default": "./dist/src/content/next/index.js" }, + "./content/next/revalidate-route": { + "import": "./dist/src/content/next/revalidate-route.server.js", + "types": "./dist/src/content/next/revalidate-route.server.d.ts", + "default": "./dist/src/content/next/revalidate-route.server.js" + }, "./api/config": { "import": "./dist/src/api/config.js", "types": "./dist/src/api/config.d.ts", diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index 90b1683ef..90bfc81c6 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -83,6 +83,7 @@ export function VitNodeAPI({ authorization: vitNodeApiConfig.authorization, dbProvider: vitNodeApiConfig.dbProvider, captcha: vitNodeApiConfig.captcha, + content: vitNodeApiConfig.content, cron: vitNodeApiConfig.cron, events: vitNodeApiConfig.events, search: vitNodeApiConfig.search, diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index f033df311..00fcd8246 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -1,5 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; +import type { AnyContentModel } from "@/content/server/model"; import type { AnyContentTypeDefinition } from "@/content/types"; import type { SearchIndexer } from "../models/search"; @@ -19,6 +20,16 @@ export interface BaseBuildModuleReturn< M extends string = string, Routes extends Route

[] = Route

[], > { + /** + * The models behind those content types - table, columns, schemas and + * services, not just the definition. + * + * Collected recursively like `contentTypes`, and exposed on the request + * context so background work can find the model for a content type id. The + * scheduled-publication queue task is the reason it exists: it runs in a cron + * request that has no idea which plugin owns the record it is publishing. + */ + contentModels?: AnyContentModel[]; /** * Content types whose CRUD routes this module serves. Unlike `events` and * `cronJobs`, these are collected recursively by `buildApiPlugin`, so a @@ -57,6 +68,7 @@ export function buildModule< pluginId, name, modules, + contentModels, contentTypes, cronJobs = [], events = [], @@ -64,6 +76,7 @@ export function buildModule< searchIndexers, webSockets = [], }: { + contentModels?: AnyContentModel[]; contentTypes?: AnyContentTypeDefinition[]; cronJobs?: BuildCronReturn[]; events?: BuildEventListenerReturn[]; @@ -95,6 +108,7 @@ export function buildModule< hono, name, modules, + contentModels, contentTypes, cronJobs, events, diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 29306c788..c559072e5 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -1,6 +1,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { RegisteredContentType } from "@/content/registry"; +import type { AnyContentModel } from "@/content/server/model"; import type { AnyContentTypeDefinition } from "@/content/types"; import type { LocaleMessagesMap } from "@/lib/i18n/types"; @@ -21,6 +22,7 @@ import { validateSearchIndexers } from "../models/search"; import { checkPluginId } from "./check-plugin-id"; export interface BuildPluginApiReturn { + contentModels?: AnyContentModel[]; contentTypes?: AnyContentTypeDefinition[]; cronJobs?: Omit[]; events?: Omit[]; @@ -56,6 +58,7 @@ export function buildApiPlugin

({ checkPluginId(pluginId); const hono = new OpenAPIHono(); + const contentModels: AnyContentModel[] = []; const contentTypes: AnyContentTypeDefinition[] = []; const cronJobs: BuildPluginApiReturn["cronJobs"] = []; const events: BuildPluginApiReturn["events"] = []; @@ -65,6 +68,7 @@ export function buildApiPlugin

({ modules.forEach(handler => { hono.route(`/${handler.name}`, handler.hono); + contentModels.push(...collectContentModels(handler)); contentTypes.push(...collectContentTypes(handler)); indexers.push(...collectSearchIndexers(handler)); @@ -95,6 +99,7 @@ export function buildApiPlugin

({ pluginId, messages, hono, + contentModels, contentTypes: registered.map(entry => entry.definition), cronJobs, events, @@ -122,6 +127,16 @@ function collectContentTypes( ]; } +/** Same walk as {@link collectContentTypes}, and for the same reason. */ +function collectContentModels( + module: BaseBuildModuleReturn, +): AnyContentModel[] { + return [ + ...(module.contentModels ?? []), + ...(module.modules ?? []).flatMap(collectContentModels), + ]; +} + function collectSearchIndexers(module: BaseBuildModuleReturn): SearchIndexer[] { return [ ...(module.searchIndexers ?? []), diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 5c5a366a3..f1efc4cd4 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -4,6 +4,7 @@ import type { Redis } from "ioredis"; import { HTTPException } from "hono/http-exception"; import type { RegisteredContentType } from "@/content/registry"; +import type { RegisteredContentModel } from "@/content/server/model"; import type { LocaleConfig, MessagesSource } from "@/lib/i18n/types"; import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config"; import type { VitNodeRealtime } from "@/ws/registry"; @@ -21,6 +22,7 @@ import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; import { StorageModel } from "@/api/models/storage"; import { validateContentTypes } from "@/content/registry"; +import { assertContentPreviewConfig } from "@/content/server/preview-config"; import { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; import { buildApiMessagesSources } from "@/lib/i18n/sources"; @@ -85,6 +87,19 @@ export interface EnvVariablesVitNode { ssoAdapters: SSOApiPlugin[]; }; captcha?: Pick["captcha"]; + /** + * Every registered content type's *model*, with the plugin that owns it. + * + * Background work has only a content type id to go on - a queue handler + * runs in a cron request with no plugin context at all - so the lookup from + * id to table, service and owner has to live somewhere it can reach. + */ + contentModels: RegisteredContentModel[]; + /** Signs content preview links. Flagged in the admin integrations panel + * while it is still the well-known default. */ + contentPreviewSecret?: string; + /** Web origins the background cache bridge posts to. */ + contentRevalidateOrigins?: string[]; contentTypes: RegisteredContentType[]; cron: (BuildCronReturn & { module: string; pluginId: string })[]; cronSecret?: string; @@ -142,6 +157,7 @@ export interface EnvVariablesVitNode { export const globalMiddleware = ({ ai, authorization, + content, metadata, email, dbProvider, @@ -158,6 +174,7 @@ export const globalMiddleware = ({ | "ai" | "authorization" | "captcha" + | "content" | "cron" | "dbProvider" | "email" @@ -237,6 +254,24 @@ export const globalMiddleware = ({ ), ); + // Once, here, because "does anything have preview enabled" is only answerable + // after every plugin's content types are in. Throws in production rather than + // booting an install whose preview links anyone could forge. + assertContentPreviewConfig({ + contentTypes: contentTypesMetadata, + secret: process.env.CONTENT_PREVIEW_SECRET, + }); + + // Not validated: a model carries the definition that `contentTypesMetadata` + // already checked, so a second pass would only repeat the same errors. + const contentModelsMetadata: RegisteredContentModel[] = plugins.flatMap( + plugin => + (plugin.contentModels ?? []).map(model => ({ + model, + pluginId: plugin.pluginId, + })), + ); + const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map( plugin => ({ pluginId: plugin.pluginId, @@ -317,6 +352,7 @@ export const globalMiddleware = ({ cookieSecure: authorization?.cookieSecure ?? true, }, captcha, + contentPreviewSecret: CONFIG.contentPreviewSecret, cronSecret: CONFIG.cronJobSecret, hasCronAdapter: !!cron, plugins: pluginsMetadata, @@ -324,6 +360,8 @@ export const globalMiddleware = ({ queue: queueMetadata, webSockets: webSocketsMetadata, permissionStaff: permissionStaffMetadata, + contentModels: contentModelsMetadata, + contentRevalidateOrigins: content?.revalidateOrigins, contentTypes: contentTypesMetadata, }); diff --git a/packages/vitnode/src/api/models/events.test.ts b/packages/vitnode/src/api/models/events.test.ts index f9b834a32..95cbee53e 100644 --- a/packages/vitnode/src/api/models/events.test.ts +++ b/packages/vitnode/src/api/models/events.test.ts @@ -210,6 +210,42 @@ describe("EventsModel.emit envelope", () => { expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/blog"); }); + it("an explicit owner wins over the context plugin", async () => { + // The queue case: core owns the handler, so the context says core, but the + // domain event belongs to whoever owns the thing it happened to. + const { adapter, publish } = captureEnvelope(); + const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/core" } }); + + await new EventsModel(ctx).emit("user.created", PAYLOAD, { + pluginId: "@vitnode/example", + }); + + expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/example"); + }); + + it("an omitted override changes nothing for existing callers", async () => { + const { adapter, publish } = captureEnvelope(); + const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/blog" } }); + + await new EventsModel(ctx).emit("user.created", PAYLOAD, {}); + + expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/blog"); + }); + + it("does not impersonate the plugin on the shared context", async () => { + // Overriding by swapping `c.get("plugin")` would change the logger, the + // permission checks and every other model on the request to fix one field. + const { adapter, publish } = captureEnvelope(); + const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/core" } }); + + await new EventsModel(ctx).emit("user.created", PAYLOAD, { + pluginId: "@vitnode/example", + }); + + expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/example"); + expect(ctx.get("plugin").id).toBe("@vitnode/core"); + }); + it("derives the actor: admin wins over user, then user, then system", async () => { const { adapter, publish } = captureEnvelope(); diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 4b5426581..96ad07aa2 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -100,6 +100,26 @@ export interface EventsApiPlugin { publish: (c: Context, envelope: EventEnvelope) => Promise; } +export interface EventEmitOptions { + /** + * Who owns the *domain event*, when that is not the plugin handling the + * request. + * + * Ownership normally comes from `c.get("plugin")`, which is right for a route: + * whoever handled the request emitted the event. It is wrong for anything that + * runs on someone else's behalf. A queue handler is the clear case - core owns + * the handler, so the context says `@vitnode/core`, but a scheduled + * `content.example.article.published` is the example plugin's event and always + * was. + * + * Pass it explicitly rather than swapping `c.get("plugin")` for the duration. + * The context is shared with the logger, the permission checks and every other + * model on the request; impersonating a plugin inside it would change all of + * them to fix one field. + */ + pluginId?: string; +} + export class EventsModel { constructor(c: Context) { this.c = c; @@ -117,10 +137,17 @@ export class EventsModel { * AFTER the writes the event describes have committed - after your awaited * inserts/updates, and after any enclosing `db.transaction` callback has * returned. + * + * **Not throwing is the contract, not an oversight.** An interactive mutation + * has already committed by the time this runs, and a listener that fell over + * is not a reason to tell the person their save failed. A caller that *does* + * need delivery to be retried - the scheduled-effects task is the one in + * core - reads `failures` and decides for itself. */ async emit( name: K, payload: VitNodeEvents[K], + options?: EventEmitOptions, ): Promise { const admin = this.c.get("admin"); const user = this.c.get("user"); @@ -129,7 +156,8 @@ export class EventsModel { name, payload, emittedAt: new Date(), - pluginId: this.c.get("plugin")?.id ?? "@vitnode/core", + pluginId: + options?.pluginId ?? this.c.get("plugin")?.id ?? "@vitnode/core", actor: admin ? { type: "admin", id: admin.user.id } : user diff --git a/packages/vitnode/src/api/models/queue.test.ts b/packages/vitnode/src/api/models/queue.test.ts index c0cd10117..382f9eb89 100644 --- a/packages/vitnode/src/api/models/queue.test.ts +++ b/packages/vitnode/src/api/models/queue.test.ts @@ -1,86 +1,126 @@ +// @vitest-environment node import type { Context } from "hono"; import { describe, expect, it, vi } from "vitest"; import { QueueModel } from "./queue"; -const makeCtx = ( - overrides: { - plugin?: { id: string }; - queue?: { maxAttempts?: number; name: string; pluginId: string }[]; - } = {}, -): { - ctx: Context; - values: ReturnType; -} => { - const values = vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([{ id: 1 }]), +/** Records what was inserted, and through which handle. */ +const harness = ({ plugin }: { plugin?: string } = {}) => { + const inserts: { handle: string; values: Record }[] = []; + + const handle = (name: string) => ({ + insert: () => ({ + values: (values: Record) => ({ + returning: async () => { + inserts.push({ handle: name, values }); + + return await Promise.resolve([{ id: 1 }]); + }, + }), + }), }); - const store: Record = { - db: { insert: vi.fn().mockReturnValue({ values }) }, - core: { queue: overrides.queue ?? [] }, - plugin: overrides.plugin, - }; - - return { - ctx: { get: (k: string) => store[k] } as unknown as Context, - values, - }; + + const c = { + get: (key: string) => + key === "db" + ? handle("request") + : key === "core" + ? { queue: [{ maxAttempts: 7, name: "known", pluginId: plugin }] } + : key === "plugin" + ? plugin + ? { id: plugin } + : undefined + : undefined, + } as unknown as Context; + + return { c, inserts, tx: handle("transaction") }; }; describe("QueueModel.dispatch", () => { - it("uses the explicit maxAttempts when provided", async () => { - const { ctx, values } = makeCtx({ - queue: [{ name: "job", pluginId: "@vitnode/core", maxAttempts: 5 }], - }); + it("stamps the requesting plugin by default", async () => { + const { c, inserts } = harness({ plugin: "@vitnode/example" }); - await new QueueModel(ctx).dispatch({ name: "job", maxAttempts: 7 }); + await new QueueModel(c).dispatch({ name: "do-something" }); - expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 7 }); + expect(inserts[0].values.pluginId).toBe("@vitnode/example"); }); - it("falls back to the registered task maxAttempts", async () => { - const { ctx, values } = makeCtx({ - queue: [{ name: "job", pluginId: "@vitnode/core", maxAttempts: 5 }], - }); + it("falls back to core when no plugin is handling the request", async () => { + const { c, inserts } = harness(); - await new QueueModel(ctx).dispatch({ name: "job" }); + await new QueueModel(c).dispatch({ name: "do-something" }); - expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 5 }); + expect(inserts[0].values.pluginId).toBe("@vitnode/core"); }); - it("defaults to 3 when the registered task has no maxAttempts", async () => { - const { ctx, values } = makeCtx({ - queue: [{ name: "job", pluginId: "@vitnode/core" }], - }); + it("stamps an explicit plugin instead", async () => { + // The case that makes scheduled publication work at all: a plugin's route + // dispatches a task core owns. The worker resolves handlers by + // `${pluginId}:${name}`, so the plugin's own id would leave the row + // unclaimable forever. + const { c, inserts } = harness({ plugin: "@vitnode/example" }); - await new QueueModel(ctx).dispatch({ name: "job" }); + await new QueueModel(c).dispatch({ + name: "content-schedule", + pluginId: "@vitnode/core", + }); - expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 3 }); + expect(inserts[0].values.pluginId).toBe("@vitnode/core"); }); - it("defaults to 3 when the task is not registered", async () => { - const { ctx, values } = makeCtx({ queue: [] }); + it("uses the request handle when no transaction is given", async () => { + const { c, inserts } = harness(); - await new QueueModel(ctx).dispatch({ name: "job" }); + await new QueueModel(c).dispatch({ name: "do-something" }); - expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 3 }); + expect(inserts[0].handle).toBe("request"); }); - it("scopes the task lookup by pluginId", async () => { - const { ctx, values } = makeCtx({ - plugin: { id: "@vitnode/blog" }, - queue: [ - { name: "job", pluginId: "@vitnode/core", maxAttempts: 5 }, - { name: "job", pluginId: "@vitnode/blog", maxAttempts: 9 }, - ], + it("joins a transaction when one is given", async () => { + // Without this the queue row can commit while the row it points at rolls + // back, and the task wakes up to find nothing there. + const { c, inserts, tx } = harness(); + + await new QueueModel(c).dispatch({ + name: "do-something", + tx: tx as never, }); - await new QueueModel(ctx).dispatch({ name: "job" }); + expect(inserts[0].handle).toBe("transaction"); + }); + + it("still reads the registered task's maxAttempts", async () => { + const { c, inserts } = harness({ plugin: "@vitnode/example" }); - expect(values.mock.calls[0][0]).toMatchObject({ - pluginId: "@vitnode/blog", - maxAttempts: 9, + await new QueueModel(c).dispatch({ name: "known" }); + + expect(inserts[0].values.maxAttempts).toBe(7); + }); + + it("looks the task up under the plugin it is dispatched for", async () => { + // `known` is registered under `@vitnode/example`, so dispatching it as core + // finds no registration and falls back to the default. + const { c, inserts } = harness({ plugin: "@vitnode/example" }); + + await new QueueModel(c).dispatch({ + name: "known", + pluginId: "@vitnode/core", }); + + expect(inserts[0].values.maxAttempts).toBe(3); + }); + + it("defaults availableAt to now, so a task runs on the next tick", async () => { + const now = new Date("2026-08-05T10:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const { c, inserts } = harness(); + await new QueueModel(c).dispatch({ name: "do-something" }); + + expect(inserts[0].values.availableAt).toEqual(now); + + vi.useRealTimers(); }); }); diff --git a/packages/vitnode/src/api/models/queue.ts b/packages/vitnode/src/api/models/queue.ts index 38f66309f..7cd68b19c 100644 --- a/packages/vitnode/src/api/models/queue.ts +++ b/packages/vitnode/src/api/models/queue.ts @@ -7,8 +7,26 @@ export interface QueueDispatchArgs { maxAttempts?: number; name: string; payload?: Record; + /** + * Who owns the handler, when that is not the plugin handling the request. + * + * The worker resolves a handler by `` `${pluginId}:${name}` ``, so a task + * registered by core but dispatched from a plugin's route needs to say so - + * otherwise the row is stamped with the plugin's id and nothing will ever + * claim it. Defaults to the requesting plugin, which is right for the + * ordinary case where a plugin dispatches its own task. + */ + pluginId?: string; priority?: number; queue?: string; + /** + * Join an existing transaction instead of using the request handle. + * + * Needed whenever the row that the task refers to is written in the same + * unit of work: without it, the queue row can commit while the row it points + * at rolls back, and the task wakes up to find nothing there. + */ + tx?: Omit; } /** @@ -27,19 +45,21 @@ export class QueueModel { async dispatch({ name, payload = {}, + pluginId: explicitPluginId, queue = "default", priority = 0, maxAttempts, availableAt, + tx, }: QueueDispatchArgs): Promise<{ id: number }> { - const pluginId = this.c.get("plugin")?.id ?? "@vitnode/core"; + const pluginId = + explicitPluginId ?? this.c.get("plugin")?.id ?? "@vitnode/core"; const registeredTask = this.c .get("core") .queue.find(task => task.pluginId === pluginId && task.name === name); - const [row] = await this.c - .get("db") + const [row] = await (tx ?? this.c.get("db")) .insert(core_queue) .values({ pluginId, diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts index 000a41381..bb760c958 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts @@ -6,7 +6,10 @@ import { core_cron } from "@/database/cron"; import { core_queue } from "@/database/queue"; import { getQueueStatus } from "@/lib/api/get-queue-status"; import { isCronStale } from "@/lib/api/is-cron-stale"; -import { INSECURE_DEFAULT_CRON_SECRET } from "@/lib/config"; +import { + INSECURE_DEFAULT_CRON_SECRET, + isSecureContentPreviewSecret, +} from "@/lib/config"; import { isRealtimePubSubEnabled, isWebSocketEnabled } from "@/ws/registry"; import { buildRoute } from "../../../../lib/route"; @@ -38,6 +41,18 @@ export const integrationsDebugAdminRoute = buildRoute({ .enum(["cloudflare_turnstile", "recaptcha_v3"]) .nullable(), }), + contentPreview: z.object({ + // `true` when at least one content type has + // `editorial.preview.enabled`, i.e. the preview routes exist. + active: z.boolean(), + // How many content types can mint preview links. + contentTypes: z.number(), + // `false` when `CONTENT_PREVIEW_SECRET` is missing, left at its + // well-known default, or too short to be a signing key. Preview + // does not merely warn in that state - it refuses to serve, and + // a production boot fails outright. + secure: z.boolean(), + }), cron: z.object({ // `true` when a cron adapter is configured, i.e. an in-process // scheduler is running the registered jobs automatically. @@ -133,6 +148,9 @@ export const integrationsDebugAdminRoute = buildRoute({ cronActivity?.lastActivity ? new Date(cronActivity.lastActivity) : null, ); const cronActive = core.hasCronAdapter; + const previewContentTypes = core.contentTypes.filter( + entry => entry.definition.editorial.preview.enabled, + ).length; const queueStatus = getQueueStatus({ cronActive, cronStale, @@ -152,6 +170,13 @@ export const integrationsDebugAdminRoute = buildRoute({ active: !!(captcha?.secretKey && captcha.siteKey), type: captcha?.type ?? null, }, + contentPreview: { + active: previewContentTypes > 0, + contentTypes: previewContentTypes, + // The same predicate the routes fail closed on, so the panel and the + // behaviour cannot disagree about what "secure" means. + secure: isSecureContentPreviewSecret(core.contentPreviewSecret), + }, cron: { active: cronActive, jobs: core.cron.length, diff --git a/packages/vitnode/src/api/modules/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts new file mode 100644 index 000000000..682152133 --- /dev/null +++ b/packages/vitnode/src/api/modules/content/content.module.ts @@ -0,0 +1,33 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { contentEditorialCleanupCron } from "./cron/content-editorial-cleanup.cron"; +import { contentScheduleEffectsQueueTask } from "./tasks/content-schedule-effects.task"; +import { contentScheduleQueueTask } from "./tasks/content-schedule.task"; + +/** + * Core's own Content Engine module: the background half. + * + * It serves no routes. It exists because `queueTasks` and `cronJobs` are + * collected from **top-level** modules only, while `buildContentAdminModule` is + * nested inside a plugin's `admin` module - so a task registered there would be + * silently dropped, with no error and no handler. + * + * One task for every schedulable content type in the install, rather than one + * per type. The handler resolves the model from `c.get("core").contentModels`, + * so adding a content type adds no task, no name to collide with, and no + * registration to forget. + * + * Two tasks rather than one, because a scheduled publication is two units of + * work with two different failure meanings: `content-schedule` moves the + * database and either commits or does not, and `content-schedule-effects` + * announces what committed and can be retried on its own without ever + * republishing. + */ +export const contentModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "content", + routes: [], + cronJobs: [contentEditorialCleanupCron], + queueTasks: [contentScheduleQueueTask, contentScheduleEffectsQueueTask], +}); diff --git a/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts new file mode 100644 index 000000000..3130c3358 --- /dev/null +++ b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts @@ -0,0 +1,59 @@ +import { buildCron } from "@/api/lib/cron"; +import { + CONTENT_REVISION_MAX_RETENTION, + CONTENT_SCHEDULE_RETENTION_DAYS, +} from "@/content/const"; +import { pruneContentRevisions } from "@/content/server/revisions-model"; +import { pruneContentSchedules } from "@/content/server/schedules-model"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Sweeps up editorial rows that no longer describe anything. + * + * Revision retention is enforced inline, in the same transaction as the write, + * so this is **not** the thing that keeps the table bounded on a healthy + * install - an install with no cron adapter must not grow forever, and it does + * not. What this handles is the case inline pruning structurally cannot: rows + * whose content type stopped existing, so nothing will ever write to them again + * and trigger a prune. + * + * Daily rather than hourly. Nothing here is urgent, and a plugin removed at + * lunchtime does not need its history gone by teatime. + */ +export const contentEditorialCleanupCron = buildCron({ + name: "content-editorial-cleanup", + description: + "Remove revisions and schedules for content types that are no longer registered, and settled schedules past their retention window.", + // 03:20 daily, off the hour so it does not pile onto every other daily job. + schedule: "20 3 * * *", + handler: async c => { + const known = c + .get("core") + .contentTypes.filter(entry => entry.definition.editorial.enabled) + .map(entry => entry.definition.id); + + const schedules = await pruneContentSchedules({ + db: c.get("db"), + knownContentTypeIds: known, + olderThan: new Date( + Date.now() - CONTENT_SCHEDULE_RETENTION_DAYS * DAY_MS, + ), + }); + + const revisions = await pruneContentRevisions({ + db: c.get("db"), + knownContentTypeIds: known, + }); + + if (schedules.orphaned + revisions.orphaned === 0) return; + + // Worth saying out loud: an unexpected number here usually means a plugin + // id or a content type id was renamed without the documented UPDATE. + await c + .get("log") + .debug( + `[content-editorial-cleanup] removed ${revisions.orphaned} orphaned revisions and ${schedules.orphaned} orphaned schedules (${schedules.settled} settled schedules aged out; revision retention stays capped at ${CONTENT_REVISION_MAX_RETENTION} per record).`, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts new file mode 100644 index 000000000..7f2b98ac8 --- /dev/null +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts @@ -0,0 +1,402 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testEditorialPostContentType } from "@/tests/content-fixtures"; + +const claimContentSchedule = vi.fn(); +const settleContentSchedule = vi.fn(); + +vi.mock("@/content/server/schedules-model", () => ({ + claimContentSchedule: (...args: unknown[]) => claimContentSchedule(...args), + settleContentSchedule: (...args: unknown[]) => settleContentSchedule(...args), +})); + +const { executeContentSchedule } = await import("./execute-content-schedule"); + +const PLUGIN_ID = "@vitnode/example"; + +const claimed = { + action: "publish" as const, + contentTypeId: testEditorialPostContentType.id, + createdBy: 3, + id: 55, + itemId: 7, + pluginId: PLUGIN_ID, +}; + +const row = { + createdAt: new Date("2026-08-01T09:00:00.000Z"), + id: 7, + publishedAt: new Date("2026-08-05T12:00:00.000Z"), + slug: "hello-world", + status: "published", + title: "Hello world", + updatedAt: new Date("2026-08-05T12:00:00.000Z"), + version: 4, +}; + +const outcome = { + changed: true, + changedFields: [], + operation: "publish" as const, + previousSlug: "hello-world", + restoredFromRevisionId: null, + revisionId: 90, + row, + version: 4, +}; + +const harness = ({ + editorial, + registered = true, +}: { + editorial?: Partial>; + registered?: boolean; +} = {}) => { + const publish = vi.fn().mockResolvedValue(outcome); + const unpublish = vi.fn().mockResolvedValue(outcome); + + const model = { + definition: testEditorialPostContentType, + editorialService: () => ({ publish, unpublish, ...editorial }), + }; + + const dispatch = vi.fn().mockResolvedValue({ id: 1 }); + let committed = false; + + const db = { + transaction: async (fn: (tx: unknown) => Promise) => { + const result = await fn({ tx: true }); + committed = true; + + return result; + }, + }; + + const c = { + get: (key: string) => + key === "db" + ? db + : key === "queue" + ? { dispatch } + : key === "core" + ? { + contentModels: registered + ? [{ model, pluginId: PLUGIN_ID }] + : [], + } + : undefined, + } as unknown as Context; + + return { c, committed: () => committed, dispatch, publish, unpublish }; +}; + +/** The single argument every effects dispatch carries. */ +const dispatchedPayload = (dispatch: ReturnType) => + dispatch.mock.calls[0][0] as { + name: string; + payload: Record; + pluginId: string; + tx?: unknown; + }; + +beforeEach(() => { + vi.clearAllMocks(); + settleContentSchedule.mockResolvedValue(true); +}); + +describe("executeContentSchedule", () => { + it("publishes, settles the schedule, and queues the announcements", async () => { + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch, publish } = harness(); + + const result = await executeContentSchedule(c, { + generation: 1, + scheduleId: 55, + }); + + expect(result.status).toBe("executed"); + expect(publish).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatchedPayload(dispatch).name).toBe("content-schedule-effects"); + }); + + describe("one transaction, from the claim to the commit", () => { + it("claims, transitions, settles and dispatches on the same handle", async () => { + // The whole point of the fix. Every one of these ran against the same + // `tx`, so the row lock `claimContentSchedule` takes is still held when + // the transition commits - which is what makes a concurrent cancel wait + // rather than succeed and then be ignored. + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch, publish } = harness(); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + const tx = { tx: true }; + expect(claimContentSchedule).toHaveBeenCalledWith(tx, expect.anything()); + expect(publish.mock.calls[0][1]).toMatchObject({ tx }); + expect(settleContentSchedule).toHaveBeenCalledWith( + tx, + 55, + expect.anything(), + ); + expect(dispatchedPayload(dispatch).tx).toEqual(tx); + }); + + it("dispatches the effects before the transaction commits", async () => { + // If the queue row could land after the commit, a crash in between would + // leave a published record nobody was ever told about. + claimContentSchedule.mockResolvedValue(claimed); + const { c, committed, dispatch } = harness(); + + dispatch.mockImplementation(async () => { + expect(committed()).toBe(false); + + return Promise.resolve({ id: 1 }); + }); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it("settles only while the schedule is still pending", async () => { + // The guard that stops a stale worker overwriting `cancelled` with + // `completed`. + claimContentSchedule.mockResolvedValue(claimed); + const { c } = harness(); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(settleContentSchedule).toHaveBeenCalledWith( + expect.anything(), + 55, + { + expectedStatus: "pending", + lastError: null, + status: "completed", + }, + ); + }); + + it("rolls the transition back when the schedule is no longer pending", async () => { + // Structurally impossible while the lock is held - so if it happens the + // lock was not held, and publishing a cancelled plan is the worse of the + // two outcomes. + claimContentSchedule.mockResolvedValue(claimed); + settleContentSchedule.mockResolvedValue(false); + const { c, dispatch } = harness(); + + await expect( + executeContentSchedule(c, { generation: 1, scheduleId: 55 }), + ).rejects.toThrow(/no longer pending/); + + expect(dispatch).not.toHaveBeenCalled(); + }); + }); + + it("runs as the system, never as a made-up user", async () => { + claimContentSchedule.mockResolvedValue(claimed); + const { c, publish } = harness(); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(publish.mock.calls[0][1]).toMatchObject({ + actor: { type: "system", userId: null }, + }); + }); + + describe("the effects payload", () => { + it("names the person who booked it", async () => { + // The actor is genuinely the system, so "on whose instruction" has to + // come from somewhere else - and it is the whole point of the audit + // trail. + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch } = harness(); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(dispatchedPayload(dispatch).payload).toMatchObject({ + contentTypeId: testEditorialPostContentType.id, + itemId: 7, + operation: "publish", + pluginId: PLUGIN_ID, + revisionId: 90, + scheduleId: 55, + scheduledBy: 3, + version: 4, + }); + }); + + it("says the record was private before a publish", async () => { + // Derived from the transition's own guard rather than read back outside + // the lock: `publish` only changes a row that was not published. + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch } = harness(); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(dispatchedPayload(dispatch).payload.wasPublic).toBe(false); + }); + + it("says the record was public before an unpublish", async () => { + claimContentSchedule.mockResolvedValue({ + ...claimed, + action: "unpublish", + }); + const { c, dispatch } = harness(); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(dispatchedPayload(dispatch).payload.wasPublic).toBe(true); + }); + + it("is JSON, so the queue can store and replay it", async () => { + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch } = harness(); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + const { row: stored } = dispatchedPayload(dispatch).payload as { + row: Record; + }; + expect(stored.publishedAt).toBe("2026-08-05T12:00:00.000Z"); + expect(stored.title).toBe("Hello world"); + }); + + it("is stamped with core, so the worker can find the handler", async () => { + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch } = harness(); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(dispatchedPayload(dispatch).pluginId).toBe("@vitnode/core"); + }); + }); + + describe("no-ops", () => { + it("does nothing when the row is cancelled, superseded or not yet due", async () => { + // All four guards collapse to the same answer from `claim`, so this is + // one test rather than four identical ones. + claimContentSchedule.mockResolvedValue(null); + const { c, dispatch, publish } = harness(); + + const result = await executeContentSchedule(c, { + generation: 1, + scheduleId: 55, + }); + + expect(result.status).toBe("skipped"); + expect(publish).not.toHaveBeenCalled(); + // The load-bearing part: a superseded task must not touch search or the + // cache, or a cancelled plan would still expire a live page. + expect(dispatch).not.toHaveBeenCalled(); + expect(settleContentSchedule).not.toHaveBeenCalled(); + }); + + it("does nothing more when the record is already published", async () => { + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch } = harness({ + editorial: { + publish: vi.fn().mockResolvedValue({ ...outcome, changed: false }), + }, + }); + + const result = await executeContentSchedule(c, { + generation: 1, + scheduleId: 55, + }); + + expect(result.status).toBe("skipped"); + expect(dispatch).not.toHaveBeenCalled(); + // Still settled, or it would be retried forever for a record that is + // already in the state the schedule wanted. + expect(settleContentSchedule).toHaveBeenCalledWith( + expect.anything(), + 55, + { + expectedStatus: "pending", + lastError: null, + status: "completed", + }, + ); + }); + + it("does nothing when the record was deleted first", async () => { + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch } = harness({ + editorial: { publish: vi.fn().mockResolvedValue(null) }, + }); + + const result = await executeContentSchedule(c, { + generation: 1, + scheduleId: 55, + }); + + expect(result.status).toBe("skipped"); + expect(dispatch).not.toHaveBeenCalled(); + }); + }); + + it("cancels rather than retrying when the content type is gone", async () => { + // A plugin removed, or `editorial` turned off. An error every ten minutes + // forever is not a useful way to report a config change. + claimContentSchedule.mockResolvedValue(claimed); + const { c, dispatch } = harness({ registered: false }); + + const result = await executeContentSchedule(c, { + generation: 1, + scheduleId: 55, + }); + + expect(result.status).toBe("unregistered"); + expect(settleContentSchedule).toHaveBeenCalledWith( + expect.anything(), + 55, + expect.objectContaining({ + expectedStatus: "pending", + status: "cancelled", + }), + ); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("records the error and rethrows a real failure", async () => { + // This one *is* worth retrying, and the queue's backoff is the policy. + claimContentSchedule.mockResolvedValue(claimed); + const { c } = harness({ + editorial: { + publish: vi.fn().mockRejectedValue(new Error("deadlock detected")), + }, + }); + + await expect( + executeContentSchedule(c, { generation: 1, scheduleId: 55 }), + ).rejects.toThrow("deadlock detected"); + + expect(settleContentSchedule).toHaveBeenCalledWith(expect.anything(), 55, { + expectedStatus: "pending", + lastError: "deadlock detected", + }); + // Left pending, so the AdminCP shows it as overdue rather than done. + expect(settleContentSchedule).not.toHaveBeenCalledWith( + expect.anything(), + 55, + expect.objectContaining({ status: "completed" }), + ); + }); + + it("passes the generation straight through to the claim", async () => { + claimContentSchedule.mockResolvedValue(null); + const { c } = harness(); + + await executeContentSchedule(c, { generation: 4, scheduleId: 55 }); + + expect(claimContentSchedule).toHaveBeenCalledWith(expect.anything(), { + generation: 4, + scheduleId: 55, + }); + }); +}); diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts new file mode 100644 index 000000000..b76415c59 --- /dev/null +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts @@ -0,0 +1,242 @@ +import type { Context } from "hono"; + +import type { ContentEditorialOutcome } from "@/content/server/editorial-service"; +import type { ContentScheduleEffectsPayload } from "@/content/server/schedule-effects"; +import type { AnyContentTypeDefinition } from "@/content/types"; + +import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const"; +import { CONTENT_SYSTEM_ACTOR } from "@/content/server/actor"; +import { findContentModel } from "@/content/server/model"; +import { + claimContentSchedule, + settleContentSchedule, +} from "@/content/server/schedules-model"; + +/** What the run decided, so the task logs something worth reading. */ +export interface ContentScheduleOutcome { + reason?: string; + status: "executed" | "skipped" | "unregistered"; +} + +/** + * Thrown when a claimed schedule is no longer `pending` at settlement time. + * + * Structurally impossible: the row is locked `FOR UPDATE` from the claim to the + * commit, so nothing else can have moved it. If it ever happens the lock was + * not held, and rolling the whole transition back is the only safe answer - + * publishing a record whose schedule somebody cancelled is worse than not + * publishing it. + */ +class ContentScheduleSettlementError extends Error { + constructor(scheduleId: number) { + super( + `Schedule ${scheduleId} was no longer pending at settlement time. Rolling the transition back.`, + ); + + this.name = "ContentScheduleSettlementError"; + } +} + +type ScheduleTransaction = + | { contentTypeId: string; kind: "unregistered" } + | { effects: ContentScheduleEffectsPayload; kind: "executed" } + | { kind: "skipped"; reason: string }; + +const slugOf = ( + definition: AnyContentTypeDefinition, + row: null | Record | undefined, +): null | string => { + if (!definition.publicApi.enabled) return null; + + const value = row?.[definition.publicApi.slugField]; + + return typeof value === "string" ? value : null; +}; + +/** + * Everything the announcements need, frozen at the moment the transition + * committed. + * + * `wasPublic` is derived rather than read back: the transition guards on the + * state it is leaving (`status <> 'published'` to publish, `= 'published'` to + * unpublish), so a *changed* publish came from a non-public row and a changed + * unpublish from a public one. That removes the extra `SELECT` the old code did + * outside the lock, and removes with it the window where the answer could have + * been someone else's write. + */ +const effectsPayload = ({ + claimed, + definition, + outcome, + pluginId, +}: { + claimed: { + action: "publish" | "unpublish"; + createdBy: null | number; + id: number; + itemId: number; + }; + definition: AnyContentTypeDefinition; + outcome: ContentEditorialOutcome; + pluginId: string; +}): ContentScheduleEffectsPayload => { + const row = outcome.row as unknown as Record; + + return { + changedFields: [...outcome.changedFields] as string[], + contentTypeId: definition.id, + itemId: claimed.itemId, + operation: claimed.action, + pluginId, + // A publish and an unpublish move `status`, never a field value, so the + // slug the record answered to before is the one it answers to now. Carried + // anyway, because the cache bridge takes a list and a future action that + // *does* move it should not need this file to change. + previousSlug: outcome.previousSlug ?? slugOf(definition, row), + revisionId: outcome.revisionId, + row: JSON.parse(JSON.stringify(row)) as Record, + scheduleId: claimed.id, + scheduledBy: claimed.createdBy, + version: outcome.version, + wasPublic: claimed.action === "unpublish", + }; +}; + +/** + * Runs one scheduled transition, or decides not to. + * + * **One transaction, from the claim to the commit.** The old shape claimed in a + * short transaction of its own and released the row lock before publishing, + * which left a real window: an administrator could cancel, be told it worked, + * and watch the article go live anyway. Now the `FOR UPDATE` taken by + * `claimContentSchedule` is held until the transition, its revision, the + * settlement *and* the effects task have all committed - so a concurrent cancel + * either wins outright (before the claim) or waits and then finds the schedule + * already `completed`, which is a truthful 404 rather than a lie. + * + * What is deliberately **not** in the transaction: the event, the search write + * and the cache bridge. They talk to systems a rollback cannot reach, so they + * are handed to `content-schedule-effects` - a queue row written in this same + * transaction, and therefore present exactly when the transition committed. + * + * Almost every guard here is a **silent no-op**, and that is the design rather + * than laziness: each one describes a schedule that is no longer the plan - + * cancelled, rescheduled, or already run. Throwing would send the queue into a + * retry loop over a decision that is never going to change. + */ +export const executeContentSchedule = async ( + c: Context, + { generation, scheduleId }: { generation: number; scheduleId: number }, +): Promise => { + const db = c.get("db"); + + let result: ScheduleTransaction; + try { + result = await db.transaction(async (tx): Promise => { + const claimed = await claimContentSchedule(tx, { + generation, + scheduleId, + }); + + if (!claimed) { + return { + kind: "skipped", + reason: "not pending, superseded, or not yet due", + }; + } + + const entry = findContentModel( + c.get("core").contentModels, + claimed.contentTypeId, + ); + const editorialService = entry?.model.editorialService; + + // The plugin was removed, or the content type dropped its editorial + // block. There is nothing to publish and there never will be, so cancel + // rather than retrying until the queue gives up - an error every ten + // minutes forever is not a useful way to report a config change. + if (!entry || !editorialService) { + await settleContentSchedule(tx, claimed.id, { + expectedStatus: "pending", + lastError: `Content type "${claimed.contentTypeId}" is no longer registered with an editorial workflow.`, + status: "cancelled", + }); + + return { contentTypeId: claimed.contentTypeId, kind: "unregistered" }; + } + + const { model, pluginId } = entry; + + const outcome = await editorialService(c, { pluginId })[claimed.action]( + claimed.itemId, + { + // No fake user id anywhere. Who *asked* for this is on the schedule + // row and travels in the event as `scheduledBy`. + actor: CONTENT_SYSTEM_ACTOR, + tx, + }, + ); + + // Settled whatever happened. A record that was deleted first, or is + // already in the state the schedule wanted, is still a schedule that + // has had its answer - leaving it pending would retry it forever. + if ( + !(await settleContentSchedule(tx, claimed.id, { + expectedStatus: "pending", + lastError: null, + status: "completed", + })) + ) { + throw new ContentScheduleSettlementError(claimed.id); + } + + if (!outcome) { + return { kind: "skipped", reason: "record no longer exists" }; + } + if (!outcome.changed) { + return { kind: "skipped", reason: "already in that state" }; + } + + const effects = effectsPayload({ + claimed, + definition: model.definition, + outcome, + pluginId, + }); + + // In the transaction, so the announcement task exists if and only if + // the transition it announces committed. A crash a millisecond later + // loses nothing: the row is durable and the queue will drain it. + await c.get("queue").dispatch({ + name: CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS, + payload: effects, + // Core owns the handler. Without this the row would be stamped with + // the requesting plugin's id and nothing would ever claim it. + pluginId: "@vitnode/core", + tx, + }); + + return { effects, kind: "executed" }; + }); + } catch (error) { + // Outside the rolled-back transaction, and guarded on `pending`: by now the + // lock is gone, so a cancel may legitimately have won the row. + await settleContentSchedule(db, scheduleId, { + expectedStatus: "pending", + lastError: error instanceof Error ? error.message : "Unknown error", + }); + + // Rethrown on purpose: this one *is* worth retrying, and the queue's + // backoff is the retry policy. + throw error; + } + + if (result.kind === "unregistered") { + return { reason: result.contentTypeId, status: "unregistered" }; + } + if (result.kind === "skipped") { + return { reason: result.reason, status: "skipped" }; + } + + return { status: "executed" }; +}; diff --git a/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts b/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts new file mode 100644 index 000000000..4583100f7 --- /dev/null +++ b/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts @@ -0,0 +1,38 @@ +import { buildQueueTask } from "@/api/lib/queue"; +import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const"; +import { + contentScheduleEffectsPayloadSchema, + runContentScheduleEffects, +} from "@/content/server/schedule-effects"; + +/** + * Announces a scheduled transition that has already committed. + * + * Unlike `content-schedule`, the payload here **is** data rather than a pointer, + * and deliberately so: the record may have been edited again by the time this + * runs, and an event describing the record's current state would announce + * something other than the publication it is reporting. Everything travels + * frozen from the transaction that wrote it. + * + * Five attempts rather than three. The failures this retries are transient by + * nature - a search node restarting, a web app redeploying - and the backoff + * (10s, 20s, 40s, 80s) is a far better fit for those than for a deadlock. + */ +export const contentScheduleEffectsQueueTask = buildQueueTask({ + name: CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS, + description: + "Emit the event, sync search and expire the cache for a scheduled publish or unpublish that has already committed. Never republishes.", + maxAttempts: 5, + handler: async (c, payload) => { + const input = contentScheduleEffectsPayloadSchema.parse(payload); + const outcome = await runContentScheduleEffects(c, input); + + if (outcome.status === "unregistered") { + await c + .get("log") + .warn( + `[content-schedule-effects] ${input.scheduleId}: ${input.contentTypeId} is no longer registered, so nothing was announced.`, + ); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts b/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts new file mode 100644 index 000000000..206152bd4 --- /dev/null +++ b/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import { buildQueueTask } from "@/api/lib/queue"; +import { CONTENT_QUEUE_TASK_SCHEDULE } from "@/content/const"; + +import { executeContentSchedule } from "../helpers/execute-content-schedule"; + +/** + * The payload is a **pointer**, not data. + * + * Everything that matters - which record, which action, whether it is still + * wanted - is re-read from the schedule row under a lock. A payload carrying + * the action would go stale the moment somebody rescheduled, and a payload + * carrying the item id would be a way to publish an arbitrary record by + * inserting a queue row. + */ +export const contentSchedulePayloadSchema = z.object({ + generation: z.number().int().positive(), + scheduleId: z.number().int().positive(), +}); + +export const contentScheduleQueueTask = buildQueueTask({ + name: CONTENT_QUEUE_TASK_SCHEDULE, + description: + "Publish or unpublish a content record at its scheduled time. A cancelled, rescheduled or already-executed schedule is a no-op.", + handler: async (c, payload) => { + const { generation, scheduleId } = + contentSchedulePayloadSchema.parse(payload); + + const outcome = await executeContentSchedule(c, { generation, scheduleId }); + if (outcome.status === "executed") return; + + const message = `[content-schedule] ${scheduleId}: ${outcome.status}${outcome.reason ? ` (${outcome.reason})` : ""}`; + + // A skip is the normal, healthy outcome for a superseded task, so it is + // `debug` - but silence would make "the schedule never fired" impossible to + // tell from "it fired and correctly did nothing". An unregistered content + // type is a real misconfiguration, so that one is a warning. + await (outcome.status === "unregistered" + ? c.get("log").warn(message) + : c.get("log").debug(message)); + }, +}); diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 9119a04f9..45cd3deb4 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -2,6 +2,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { buildApiPlugin } from "./lib/plugin"; import { adminModule } from "./modules/admin/admin.module"; +import { contentModule } from "./modules/content/content.module"; import { cronModule } from "./modules/cron/cron.module"; import { middlewareModule } from "./modules/middleware/middleware.module"; import { queueModule } from "./modules/queue/queue.module"; @@ -14,6 +15,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ middlewareModule, usersModule, adminModule, + contentModule, cronModule, queueModule, searchModule, diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index d153202b1..b250e8eea 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -59,14 +59,16 @@ export type ContentEnumLabeller = (name: string, value: string) => string; /** * 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. + * which the cell renderer treats as a date - and `version` is mapped to + * "number" for the same reason, since it is one. */ -const systemKinds: Record = { +const systemKinds: Record = { createdAt: "system", id: "system", publishedAt: "system", status: "publication", updatedAt: "system", + version: "number", }; /** Projects a definition's form fields into the serialisable spec. */ diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index ede4c02ea..6be317b9c 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -38,6 +38,16 @@ export const contentPublicSlugTag = ( slug: string, ): string => tag(contentTypeId, "slug", slug); +/** + * How hard a mutation expires the tags it touched. + * + * Lives here, in the client-safe layer, because the background + * [bridge](./server/revalidate-bridge.ts) has to name a mode from a process + * where `next/cache` cannot even be imported. `content/next` re-exports it, so + * the public name has not moved. + */ +export type ContentInvalidationMode = "immediate" | "stale-while-revalidate"; + export interface ContentInvalidationInput { contentTypeId: string; id: number; diff --git a/packages/vitnode/src/content/conflicts.ts b/packages/vitnode/src/content/conflicts.ts new file mode 100644 index 000000000..ac3e2fbbd --- /dev/null +++ b/packages/vitnode/src/content/conflicts.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { + CONTENT_CONFLICT_CODES, + CONTENT_SCHEDULE_CODES, + CONTENT_UNPROCESSABLE_CODES, +} from "./const"; + +export type ContentConflictCode = + (typeof CONTENT_CONFLICT_CODES)[keyof typeof CONTENT_CONFLICT_CODES]; + +export type ContentUnprocessableCode = + (typeof CONTENT_UNPROCESSABLE_CODES)[keyof typeof CONTENT_UNPROCESSABLE_CODES]; + +/** + * The 409 body an editorial route answers with. + * + * A discriminated union so one OpenAPI schema describes the whole status: a + * generated client branches on `code` rather than parsing English. Only + * editorial content types answer this way - a Stage 1-3 route keeps the plain + * text 409 it has always returned, so nothing existing changes shape. + */ +export const zodContentConflict = z.discriminatedUnion("code", [ + z.object({ + code: z.literal(CONTENT_CONFLICT_CODES.version), + contentTypeId: z.string(), + currentVersion: z.number().int(), + expectedVersion: z.number().int(), + itemId: z.number().int(), + }), + z.object({ + code: z.literal(CONTENT_CONFLICT_CODES.unique), + contentTypeId: z.string(), + itemId: z.number().int().nullable(), + }), +]); + +export type ContentConflict = z.infer; + +/** The 422 body a restore answers with when the snapshot no longer fits. */ +export const zodContentUnprocessable = z.object({ + code: z.literal(CONTENT_UNPROCESSABLE_CODES.notRestorable), + contentTypeId: z.string(), + /** + * The content type's own field names, and nothing else. Never a Zod issue + * tree - that names internal paths, and the route's OpenAPI schema already + * describes the contract. + */ + fields: z.array(z.string()), + revisionId: z.number().int(), +}); + +export type ContentUnprocessable = z.infer; + +/** + * The 400 body a refused schedule answers with. + * + * A code rather than prose for the same reason the 409 carries one: the dialog + * points at the date field for one of these and shows a general error for the + * other, and it cannot branch on English. + */ +export const zodContentScheduleRejection = z.object({ + code: z.enum([ + CONTENT_SCHEDULE_CODES.inPast, + CONTENT_SCHEDULE_CODES.order, + CONTENT_SCHEDULE_CODES.unsupported, + ]), + contentTypeId: z.string(), +}); + +export type ContentScheduleRejection = z.infer< + typeof zodContentScheduleRejection +>; + +/** Reads a schedule rejection out of a response body, or `null`. */ +export const parseContentScheduleRejection = ( + body: string | undefined, +): ContentScheduleRejection | null => { + if (body === undefined || body === "") return null; + + try { + const parsed = zodContentScheduleRejection.safeParse(JSON.parse(body)); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } +}; + +/** + * Reads a structured error out of a response body. + * + * Returns `null` for anything that does not match - a plain-text 409 from a + * non-editorial route, an HTML error page from a proxy - so a caller can fall + * back to its generic message instead of throwing on the error path. + */ +export const parseContentConflict = ( + body: string | undefined, +): ContentConflict | null => { + if (body === undefined || body === "") return null; + + try { + const parsed = zodContentConflict.safeParse(JSON.parse(body)); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } +}; + +export const parseContentUnprocessable = ( + body: string | undefined, +): ContentUnprocessable | null => { + if (body === undefined || body === "") return null; + + try { + const parsed = zodContentUnprocessable.safeParse(JSON.parse(body)); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } +}; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 22db51cf8..3e4ea6ffb 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -2,6 +2,16 @@ export const CONTENT_SYSTEM_FIELDS = ["id", "createdAt", "updatedAt"] as const; export const CONTENT_PUBLICATION_FIELDS = ["status", "publishedAt"] as const; +/** + * The column `editorial: { enabled: true }` adds. + * + * Its own list rather than an entry in `CONTENT_SYSTEM_FIELDS`, for the same + * reason the publication fields are separate: it exists only for a content type + * that opted in, so a Stage 1 type stays free to declare a field of its own + * called `version`. + */ +export const CONTENT_EDITORIAL_FIELDS = ["version"] as const; + export const CONTENT_PUBLICATION_STATUSES = ["draft", "published"] as const; const publicationStatuses: ReadonlySet = new Set( @@ -129,14 +139,155 @@ export const CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH = 100; export const CONTENT_SEARCH_PATH_MAX_LENGTH = 512; +/** + * What a revision records. + * + * One per *real* mutation - a no-op update, an idempotent publish and a + * cancelled schedule all write nothing at all. + */ +export const CONTENT_REVISION_OPERATIONS = [ + "create", + "delete", + "publish", + "restore", + "unpublish", + "update", +] as const; + +/** + * Who performed a mutation. + * + * `system` exists so a scheduled publish needs no fake user id. Who *created* + * the schedule is kept on the schedule row, not invented here. + */ +export const CONTENT_ACTOR_TYPES = ["api", "staff", "system"] as const; + +/** The envelope `snapshot` is stored in, so a future shape change is visible. */ +export const CONTENT_REVISION_SNAPSHOT_VERSION = 1; + +/** + * How many of the newest revisions are kept per record. + * + * Pruned in the same transaction that writes the new one, so the table stays + * bounded without a background job - an install with no cron adapter must not + * grow forever. + */ +export const CONTENT_REVISION_DEFAULT_RETENTION = 50; +export const CONTENT_REVISION_MIN_RETENTION = 1; +export const CONTENT_REVISION_MAX_RETENTION = 500; + +/** + * How long a preview link stays valid. + * + * The ceiling is a day: a preview token is a bearer credential for an + * unpublished record, and its expiry is the only thing that revokes it. + */ +export const CONTENT_PREVIEW_DEFAULT_TTL_MINUTES = 15; +export const CONTENT_PREVIEW_MIN_TTL_MINUTES = 1; +export const CONTENT_PREVIEW_MAX_TTL_MINUTES = 1440; + +/** The only placeholder `editorial.preview.pathTemplate` may use. */ +export const CONTENT_PREVIEW_TOKEN_PLACEHOLDER = "{token}"; + +/** + * The preview token format. + * + * Carried inside the signed payload so a future change to the shape is a + * rejected token rather than a misread one - old links stop working, which is + * the correct outcome for a credential whose meaning moved. + */ +export const CONTENT_PREVIEW_TOKEN_VERSION = 1; + +export const CONTENT_PREVIEW_PATH_MAX_LENGTH = 512; + +/** What a schedule does when it fires. */ +export const CONTENT_SCHEDULE_ACTIONS = ["publish", "unpublish"] as const; + +/** + * Where a schedule is in its life. + * + * There is deliberately no `failed`. Marking one would need the handler to know + * the queue row's attempt count, which it never receives - and an overdue + * `pending` row with `lastError` set says the same thing with one fewer state + * that can be wrong. + */ +export const CONTENT_SCHEDULE_STATUSES = [ + "cancelled", + "completed", + "pending", +] as const; + +/** + * How far in the past a `scheduledFor` may be and still be accepted. + * + * One cron tick plus slack. A browser clock a minute behind the server is + * ordinary, and "now" is what the editor meant - rejecting it would be a + * puzzle, not a safeguard. Anything earlier is a mistake worth naming. + */ +export const CONTENT_SCHEDULE_PAST_TOLERANCE_MS = 120_000; + +/** How long a completed or cancelled schedule is kept as an audit trail. */ +export const CONTENT_SCHEDULE_RETENTION_DAYS = 30; + +/** + * The single core queue task that executes every content schedule. + * + * One task rather than one per content type: `queueTasks` are collected from + * top-level modules only, and `buildContentAdminModule` is nested inside a + * plugin's admin module - so a task registered there would be silently dropped. + */ +export const CONTENT_QUEUE_TASK_SCHEDULE = "content-schedule"; + +/** + * The follow-up task that announces a schedule that has already happened. + * + * Separate from {@link CONTENT_QUEUE_TASK_SCHEDULE} because the two have + * different failure meanings. The transition is a database write that either + * committed or did not; the effects are an event, a search write and an HTTP + * hop to another process, any of which can fail long after the record is + * already published. Retrying them together would re-run an idempotent publish + * that then skips its own announcements - which is how a scheduled unpublish + * ends up permanently missing its cache invalidation. + * + * Dispatched **inside** the transition's transaction, so the task exists if and + * only if the transition committed. + */ +export const CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS = "content-schedule-effects"; + +/** Machine-readable reasons a schedule was refused. */ +export const CONTENT_SCHEDULE_CODES = { + inPast: "CONTENT_SCHEDULE_IN_PAST", + order: "CONTENT_SCHEDULE_ORDER", + unsupported: "CONTENT_SCHEDULE_UNSUPPORTED", +} as const; + /** * Every content type gets the first four staff permissions. `can_publish` is - * generated only for content types with `publication: { enabled: true }`. + * generated only for content types with `publication: { enabled: true }`, and + * `can_restore` only for those with `editorial: { enabled: true }`. */ export const CONTENT_PERMISSIONS = { create: "can_create", delete: "can_delete", edit: "can_edit", publish: "can_publish", + restore: "can_restore", view: "can_view", } as const; + +/** + * Machine-readable reasons a write was refused. + * + * A code rather than a sentence, because the AdminCP has to *act* on the + * difference - a version conflict reloads the record and offers to overwrite, a + * unique clash points at a field. Prose cannot be branched on, and the + * driver's own message must never reach a client. + */ +export const CONTENT_CONFLICT_CODES = { + unique: "CONTENT_UNIQUE_CONFLICT", + version: "CONTENT_VERSION_CONFLICT", +} as const; + +export const CONTENT_UNPROCESSABLE_CODES = { + notRestorable: "CONTENT_REVISION_NOT_RESTORABLE", +} as const; diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts index 8521ab7a2..80e2fd8aa 100644 --- a/packages/vitnode/src/content/define.test.ts +++ b/packages/vitnode/src/content/define.test.ts @@ -8,6 +8,12 @@ import { import type { ContentUserField } from "./types"; +import { + CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, + CONTENT_REVISION_DEFAULT_RETENTION, + CONTENT_REVISION_MAX_RETENTION, + CONTENT_REVISION_MIN_RETENTION, +} from "./const"; import { defineContentType } from "./define"; import { ContentEngineError } from "./errors"; import { field } from "./fields"; @@ -511,6 +517,204 @@ describe("defineContentType", () => { }); }); + describe("editorial", () => { + type Overrides = NonNullable[0]>; + + const editorialDefine = ( + editorial: Overrides["editorial"], + overrides: Overrides = {}, + ) => define({ editorial, ...overrides }); + + const publishable = { + publication: { enabled: true } as const, + publicApi: { + enabled: true, + path: "widgets", + fields: ["title", "slug"], + } as const, + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + }, + }; + + describe("defaults", () => { + it("resolves to disabled when omitted", () => { + expect(define().editorial).toEqual({ + enabled: false, + preview: { + enabled: false, + expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, + pathTemplate: null, + }, + revisions: { retention: CONTENT_REVISION_DEFAULT_RETENTION }, + scheduling: { enabled: false }, + }); + }); + + it("fills in the defaults when opted in with nothing else", () => { + expect(editorialDefine({ enabled: true }).editorial).toEqual({ + enabled: true, + preview: { + enabled: false, + expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, + pathTemplate: null, + }, + revisions: { retention: CONTENT_REVISION_DEFAULT_RETENTION }, + scheduling: { enabled: false }, + }); + }); + + it("keeps a declared retention", () => { + expect( + editorialDefine({ enabled: true, revisions: { retention: 5 } }) + .editorial.revisions.retention, + ).toBe(5); + }); + }); + + describe("retention validation", () => { + it.each([0, -1, 501, 1.5])("rejects a retention of %s", retention => { + expect(() => + editorialDefine({ enabled: true, revisions: { retention } }), + ).toThrow(ContentEngineError); + }); + + it.each([CONTENT_REVISION_MIN_RETENTION, CONTENT_REVISION_MAX_RETENTION])( + "accepts the boundary %s", + retention => { + expect(() => + editorialDefine({ enabled: true, revisions: { retention } }), + ).not.toThrow(); + }, + ); + }); + + describe("preview", () => { + const withPreview = (preview: { + enabled: true; + expiresInMinutes?: number; + pathTemplate?: string; + }): ReturnType => + editorialDefine({ enabled: true, preview }, publishable); + + it("needs a public API", () => { + expect(() => + editorialDefine( + { enabled: true, preview: { enabled: true } }, + { publication: { enabled: true } }, + ), + ).toThrow(/needs `publicApi/); + }); + + it("resolves its defaults", () => { + expect(withPreview({ enabled: true }).editorial.preview).toEqual({ + enabled: true, + expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, + pathTemplate: null, + }); + }); + + it.each([0, 1441, 2.5])("rejects a TTL of %s minutes", value => { + expect(() => + withPreview({ enabled: true, expiresInMinutes: value }), + ).toThrow(ContentEngineError); + }); + + it.each([ + ["widgets/preview/{token}", "no leading slash"], + ["/widgets/preview", "no placeholder"], + ["/widgets/{token}/{token}", "two placeholders"], + ["/widgets/{id}/{token}", "an unsupported placeholder"], + ["/widgets//preview/{token}", "an empty segment"], + ["/widgets/../{token}", "a traversal"], + ["/widgets/pre view/{token}", "whitespace"], + ])("rejects the pathTemplate %s (%s)", pathTemplate => { + expect(() => withPreview({ enabled: true, pathTemplate })).toThrow( + ContentEngineError, + ); + }); + + it("accepts a well-formed pathTemplate", () => { + expect( + withPreview({ + enabled: true, + pathTemplate: "/widgets/preview/{token}", + }).editorial.preview.pathTemplate, + ).toBe("/widgets/preview/{token}"); + }); + }); + + describe("scheduling", () => { + it("needs publication", () => { + expect(() => + editorialDefine({ enabled: true, scheduling: { enabled: true } }), + ).toThrow(/needs `publication/); + }); + + it("is enabled alongside publication", () => { + expect( + editorialDefine( + { enabled: true, scheduling: { enabled: true } }, + { publication: { enabled: true } }, + ).editorial.scheduling.enabled, + ).toBe(true); + }); + }); + + describe("reserved field name", () => { + const versionField = { + title: field.text({ required: true }), + version: field.number({ integer: true, defaultValue: 0 }), + }; + + it("rejects a field called `version` once enabled", () => { + expect(() => + editorialDefine({ enabled: true }, { fields: versionField }), + ).toThrow(/generated by `editorial`/); + }); + + it("allows it when editorial is omitted", () => { + expect(() => define({ fields: versionField })).not.toThrow(); + }); + }); + + it("rejects a content type id too long to store on a revision", () => { + expect(() => + editorialDefine( + { enabled: true }, + { id: `test.${"a".repeat(100)}`, tableName: "test_long_id" }, + ), + ).toThrow(/limit for a revision/); + }); + + describe("addressable column", () => { + it("accepts `version` in the admin list once enabled", () => { + expect( + editorialDefine( + { enabled: true }, + { admin: { label, list: { columns: ["title", "version"] } } }, + ).admin.list.columns, + ).toEqual(["title", "version"]); + }); + + it("rejects it when editorial is off", () => { + expect(() => + define({ admin: { label, list: { columns: ["title", "version"] } } }), + ).toThrow(/unknown field "version"/); + }); + + it("accepts an index over it once enabled", () => { + expect(() => + editorialDefine( + { enabled: true }, + { indexes: [{ on: ["version"] }] }, + ), + ).not.toThrow(); + }); + }); + }); + describe("fixtures", () => { it("resolves the article fixture", () => { expect(testArticleContentType.permissionModule).toBe("test_articles"); diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 64bd64d8f..b0cd3d98b 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1,12 +1,16 @@ import type { ContentAdminConfig, + ContentEditorialConfig, + ContentEditorialEnabled, ContentFieldDescriptor, ContentFieldMap, ContentFieldsConstraint, ContentIndexInput, + ContentPreviewEnabled, ContentPublicApiConfig, ContentPublicationConfig, ContentPublicExposableField, + ContentSchedulingEnabled, ContentSearchConfig, ContentSearchDescriptionField, ContentSearchEnabled, @@ -14,16 +18,23 @@ import type { ContentSearchTitleField, ContentTypeDefinition, ResolvedContentAdminConfig, + ResolvedContentEditorialConfig, ResolvedContentPublicApiConfig, ResolvedContentSearchConfig, } from "./types"; import { + CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FIELD_NAME_PATTERN, CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_ID_PATTERN, CONTENT_IDENTIFIER_MAX_LENGTH, + CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, + CONTENT_PREVIEW_MAX_TTL_MINUTES, + CONTENT_PREVIEW_MIN_TTL_MINUTES, + CONTENT_PREVIEW_PATH_MAX_LENGTH, + CONTENT_PREVIEW_TOKEN_PLACEHOLDER, CONTENT_PUBLIC_ALWAYS_ORDERABLE, CONTENT_PUBLIC_EXPOSABLE_COLUMNS, CONTENT_PUBLIC_EXPOSABLE_KINDS, @@ -31,6 +42,9 @@ import { CONTENT_PUBLIC_PATH_PATTERN, CONTENT_PUBLIC_RESERVED_PATHS, CONTENT_PUBLICATION_FIELDS, + CONTENT_REVISION_DEFAULT_RETENTION, + CONTENT_REVISION_MAX_RETENTION, + CONTENT_REVISION_MIN_RETENTION, CONTENT_SEARCH_DESCRIPTION_KINDS, CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH, CONTENT_SEARCH_PATH_MAX_LENGTH, @@ -66,6 +80,7 @@ const SLUG_SOURCE_KINDS = new Set(["text"]); const systemFields: readonly string[] = CONTENT_SYSTEM_FIELDS; const publicationFields: readonly string[] = CONTENT_PUBLICATION_FIELDS; +const editorialFields: readonly string[] = CONTENT_EDITORIAL_FIELDS; const slugifyModule = (value: string): string => value @@ -91,6 +106,7 @@ const assertFieldName = ( id: string, name: string, publication: boolean, + editorial: boolean, ): void => { if (systemFields.includes(name)) { throw new ContentEngineError( @@ -106,6 +122,13 @@ const assertFieldName = ( ); } + if (editorial && editorialFields.includes(name)) { + throw new ContentEngineError( + `"${name}" is generated by \`editorial\` and cannot also be declared as a field. Rename the field, or drop \`editorial\` and manage versioning yourself.`, + { contentTypeId: id }, + ); + } + if (!CONTENT_FIELD_NAME_PATTERN.test(name)) { throw new ContentEngineError( `Field "${name}" must be camelCase and start with a lowercase letter.`, @@ -286,11 +309,14 @@ const resolveAdmin = ( fields: ContentFieldMap, admin: ContentAdminConfig, publication: boolean, + editorial: boolean, ): ResolvedContentAdminConfig => { const fieldNames = Object.keys(fields); - const generatedColumns = publication - ? [...systemFields, ...publicationFields] - : systemFields; + const generatedColumns = [ + ...systemFields, + ...(publication ? publicationFields : []), + ...(editorial ? editorialFields : []), + ]; const knownColumns = new Set([...fieldNames, ...generatedColumns]); const searchableFields = ( @@ -785,6 +811,167 @@ const resolveSearch = ( }; }; +const disabledEditorial: ResolvedContentEditorialConfig = { + enabled: false, + preview: { + enabled: false, + expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, + pathTemplate: null, + }, + revisions: { retention: CONTENT_REVISION_DEFAULT_RETENTION }, + scheduling: { enabled: false }, +}; + +/** + * The same rules as `search.pathTemplate`, with `{token}` in place of `{slug}`. + * + * Deliberately not shared with it: the two differ in their placeholder and in + * the config key their messages name, and a parameterised version would say + * less about what the author got wrong. + */ +const assertPreviewPathTemplate = (id: string, template: string): void => { + if (!template.startsWith("/")) { + throw new ContentEngineError( + `editorial.preview.pathTemplate "${template}" must start with "/". A preview URL is relative to the site root.`, + { contentTypeId: id }, + ); + } + + if (template.length > CONTENT_PREVIEW_PATH_MAX_LENGTH) { + throw new ContentEngineError( + `editorial.preview.pathTemplate "${template}" is longer than ${CONTENT_PREVIEW_PATH_MAX_LENGTH} characters.`, + { contentTypeId: id }, + ); + } + + const occurrences = + template.split(CONTENT_PREVIEW_TOKEN_PLACEHOLDER).length - 1; + if (occurrences !== 1) { + throw new ContentEngineError( + `editorial.preview.pathTemplate "${template}" must contain exactly one "${CONTENT_PREVIEW_TOKEN_PLACEHOLDER}" placeholder, not ${occurrences}.`, + { contentTypeId: id }, + ); + } + + const rest = template.replace(CONTENT_PREVIEW_TOKEN_PLACEHOLDER, ""); + if (rest.includes("{") || rest.includes("}")) { + throw new ContentEngineError( + `editorial.preview.pathTemplate "${template}" uses a placeholder other than "${CONTENT_PREVIEW_TOKEN_PLACEHOLDER}". No other placeholder is supported.`, + { contentTypeId: id }, + ); + } + + if (rest.includes("//") || template.includes("..") || /\s/.test(template)) { + throw new ContentEngineError( + `editorial.preview.pathTemplate "${template}" must not contain an empty segment, "..", or whitespace.`, + { contentTypeId: id }, + ); + } +}; + +const assertInRange = ({ + id, + label, + max, + min, + value, +}: { + id: string; + label: string; + max: number; + min: number; + value: number; +}): void => { + if (!Number.isInteger(value) || value < min || value > max) { + throw new ContentEngineError( + `${label} must be a whole number between ${min} and ${max}, got ${value}.`, + { contentTypeId: id }, + ); + } +}; + +/** + * Checks and fills in `editorial`. + * + * Runs last, because both sub-features are stated in terms of capabilities the + * other resolvers have already settled. The two dependency checks repeat what + * the types already say, for the same reason every other one does: a JavaScript + * caller, or a value that widened somewhere upstream, can reach this with + * anything at all. + */ +const resolveEditorial = ( + id: string, + editorial: ContentEditorialConfig | undefined, + publicApi: ResolvedContentPublicApiConfig, + publication: boolean, +): ResolvedContentEditorialConfig => { + if (!editorial?.enabled) return disabledEditorial; + + const retention = + editorial.revisions?.retention ?? CONTENT_REVISION_DEFAULT_RETENTION; + assertInRange({ + id, + label: "editorial.revisions.retention", + max: CONTENT_REVISION_MAX_RETENTION, + min: CONTENT_REVISION_MIN_RETENTION, + value: retention, + }); + + // A content type id is used verbatim as `core_content_revisions.contentTypeId`, + // which is varchar(100) - the same limit `search` enforces, and worth + // catching here rather than at the first insert. + if (id.length > CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH) { + throw new ContentEngineError( + `Content type id "${id}" is longer than ${CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH} characters, which is the limit for a revision's stored content type.`, + { contentTypeId: id }, + ); + } + + const preview = + editorial.preview?.enabled === true ? editorial.preview : null; + if (preview && !publicApi.enabled) { + throw new ContentEngineError( + "editorial.preview needs `publicApi: { enabled: true, path, fields }`. A preview returns the public projection of a draft, so without a public allowlist there is nothing it could safely show.", + { contentTypeId: id }, + ); + } + + const expiresInMinutes = + preview?.expiresInMinutes ?? CONTENT_PREVIEW_DEFAULT_TTL_MINUTES; + if (preview) { + assertInRange({ + id, + label: "editorial.preview.expiresInMinutes", + max: CONTENT_PREVIEW_MAX_TTL_MINUTES, + min: CONTENT_PREVIEW_MIN_TTL_MINUTES, + value: expiresInMinutes, + }); + + if (preview.pathTemplate !== undefined) { + assertPreviewPathTemplate(id, preview.pathTemplate); + } + } + + const scheduling = editorial.scheduling?.enabled === true; + if (scheduling && !publication) { + throw new ContentEngineError( + "editorial.scheduling needs `publication: { enabled: true }`. A schedule moves `status`, and without the lifecycle there is no status to move.", + { contentTypeId: id }, + ); + } + + return { + enabled: true, + preview: { + enabled: preview !== null, + expiresInMinutes, + pathTemplate: preview?.pathTemplate ?? null, + }, + revisions: { retention }, + scheduling: { enabled: scheduling }, + }; +}; + /** * 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 @@ -793,7 +980,10 @@ const resolveSearch = ( */ export const defineContentType = < TId extends string, - TFields extends ContentFieldsConstraint, + TFields extends ContentFieldsConstraint< + TPublication, + ContentEditorialEnabled + >, TPublication extends boolean = false, TPublicField extends ContentPublicExposableField = never, TPublicEnabled extends boolean = false, @@ -815,8 +1005,18 @@ export const defineContentType = < ContentSearchTextField > | { enabled: false } = { enabled: false }, + // The whole `editorial` argument, inferred as one type, for the same two + // reasons `TSearch` is: its constraint is checked once `TPublicEnabled` and + // `TPublication` are resolved - which is what makes "preview needs a public + // API" and "scheduling needs publication" compile errors - and an + // intersection member is not an inference site, so inferring the object is + // the only way the three `enabled` literals survive. + TEditorial extends + ContentEditorialConfig | { enabled: false } = + { enabled: false }, >({ admin, + editorial, fields, id, indexes = [], @@ -825,10 +1025,24 @@ export const defineContentType = < search, tableName, }: { - admin: ContentAdminConfig; + admin: ContentAdminConfig< + TFields, + TPublication, + ContentEditorialEnabled + >; + /** + * Opts into the editorial workflow: a `version` column, optimistic locking + * and revision history, plus optional preview and scheduling. Omit it and + * nothing changes. + */ + editorial?: TEditorial; fields: TFields; id: TId; - indexes?: ContentIndexInput[]; + indexes?: ContentIndexInput< + TFields, + TPublication, + ContentEditorialEnabled + >[]; /** * Opts into a generated read-only public API. Needs `publication` and exactly * one exposed slug field. Omit it and nothing public is generated. @@ -850,7 +1064,10 @@ export const defineContentType = < TPublication, TPublicField, TPublicEnabled, - ContentSearchEnabled + ContentSearchEnabled, + ContentEditorialEnabled, + ContentPreviewEnabled, + ContentSchedulingEnabled > => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( @@ -885,9 +1102,10 @@ export const defineContentType = < } const publicationEnabled = publication?.enabled === true; + const editorialEnabled = editorial?.enabled === true; for (const name of fieldNames) { - assertFieldName(id, name, publicationEnabled); + assertFieldName(id, name, publicationEnabled, editorialEnabled); assertFieldKind(id, name, fieldMap[name]); assertField(id, name, fieldMap[name]); } @@ -898,6 +1116,7 @@ export const defineContentType = < ...fieldNames, ...systemFields, ...(publicationEnabled ? publicationFields : []), + ...(editorialEnabled ? editorialFields : []), ]); const resolvedIndexes = resolveContentIndexes({ contentTypeId: id, @@ -912,7 +1131,13 @@ export const defineContentType = < tableName, }); - const resolvedAdmin = resolveAdmin(id, fieldMap, admin, publicationEnabled); + const resolvedAdmin = resolveAdmin( + id, + fieldMap, + admin, + publicationEnabled, + editorialEnabled, + ); const permissionModule = admin.permissionModule ?? slugifyModule(admin.label.plural); @@ -943,8 +1168,22 @@ export const defineContentType = < publicationEnabled, ); + const resolvedEditorial = resolveEditorial( + id, + // The `{ enabled: false }` arm of the parameter exists only so an explicit + // literal typechecks - the same widening `publicApi` and `search` do. + editorial as ContentEditorialConfig | undefined, + resolvedPublicApi, + publicationEnabled, + ); + return { admin: resolvedAdmin, + editorial: resolvedEditorial as ResolvedContentEditorialConfig< + ContentEditorialEnabled, + ContentPreviewEnabled, + ContentSchedulingEnabled + >, fields, id, indexes: resolvedIndexes, @@ -963,10 +1202,14 @@ export const defineContentType = < TPublication, TPublicField, TPublicEnabled, - ContentSearchEnabled + ContentSearchEnabled, + ContentEditorialEnabled, + ContentPreviewEnabled, + ContentSchedulingEnabled > >({ admin: resolvedAdmin, + editorial: editorialEnabled, fields: fieldMap, publicApi: resolvedPublicApi, publication: publicationEnabled, diff --git a/packages/vitnode/src/content/editorial.test-d.ts b/packages/vitnode/src/content/editorial.test-d.ts new file mode 100644 index 000000000..486d9155a --- /dev/null +++ b/packages/vitnode/src/content/editorial.test-d.ts @@ -0,0 +1,299 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, + testEditorialNoteContentType, + testEditorialPostContentType, + testPostContentType, + testSearchablePostContentType, +} from "@/tests/content-fixtures"; + +import type { ContentEventsFor } from "./events"; +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentOrderableFieldName, + ContentSelect, + ContentUpdateInput, + EditorialContentTypeDefinition, + PreviewableContentTypeDefinition, + SchedulableContentTypeDefinition, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +type Editorial = typeof testEditorialPostContentType; +type Note = typeof testEditorialNoteContentType; +type Post = typeof testPostContentType; + +describe("editorial", () => { + // Three more type parameters on `ContentTypeDefinition`, and this is what says + // they cost nothing: the erased form every relation thunk, registry and route + // builder is written against still accepts every concrete definition. + describe("assignability to AnyContentTypeDefinition", () => { + it("holds for an editorial content type", () => { + expectTypeOf().toExtend(); + assertType(testEditorialPostContentType); + assertType(testEditorialNoteContentType); + }); + + it("still holds for every Stage 1-3 fixture", () => { + assertType(testCategoryContentType); + assertType(testArticleContentType); + assertType(testPostContentType); + assertType(testSearchablePostContentType); + }); + }); + + describe("the flags stay literal", () => { + it("is `true` when opted in", () => { + expectTypeOf( + testEditorialPostContentType.editorial.enabled, + ).toEqualTypeOf(); + expectTypeOf( + testEditorialPostContentType.editorial.preview.enabled, + ).toEqualTypeOf(); + expectTypeOf( + testEditorialPostContentType.editorial.scheduling.enabled, + ).toEqualTypeOf(); + }); + + it("is `false` when omitted", () => { + expectTypeOf( + testPostContentType.editorial.enabled, + ).toEqualTypeOf(); + expectTypeOf( + testArticleContentType.editorial.enabled, + ).toEqualTypeOf(); + }); + + it("keeps the two sub-features independent", () => { + // Revisions without publication, so neither sub-feature is expressible. + expectTypeOf( + testEditorialNoteContentType.editorial.enabled, + ).toEqualTypeOf(); + expectTypeOf( + testEditorialNoteContentType.editorial.preview.enabled, + ).toEqualTypeOf(); + expectTypeOf( + testEditorialNoteContentType.editorial.scheduling.enabled, + ).toEqualTypeOf(); + }); + }); + + describe("capability rules", () => { + it("allows revisions with no publication and no public API", () => { + expectTypeOf().toExtend(); + }); + + it("rejects preview without a public API", () => { + defineContentType({ + id: "test.no-public", + tableName: "test_no_public", + fields: { title: field.text({ required: true }) }, + publication: { enabled: true }, + editorial: { + enabled: true, + // @ts-expect-error - preview projects through `publicApi.fields` + preview: { enabled: true }, + }, + admin: { label: { plural: "Nopes", singular: "Nope" } }, + }); + }); + + it("rejects scheduling without publication", () => { + defineContentType({ + id: "test.no-lifecycle", + tableName: "test_no_lifecycle", + fields: { title: field.text({ required: true }) }, + editorial: { + enabled: true, + // @ts-expect-error - a schedule moves `status` + scheduling: { enabled: true }, + }, + admin: { label: { plural: "Nopes", singular: "Nope" } }, + }); + }); + }); + + describe("narrowing intersections", () => { + it("pins the fully-configured content type", () => { + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + }); + + it("excludes a content type without the workflow", () => { + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + }); + + it("excludes an editorial content type that opted into neither extra", () => { + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + }); + }); + + describe("select output", () => { + it("gains the generated version column", () => { + expectTypeOf< + ContentSelect["version"] + >().toEqualTypeOf(); + expectTypeOf["version"]>().toEqualTypeOf(); + }); + + it("adds nothing to a content type without the workflow", () => { + expectTypeOf>().not.toHaveProperty("version"); + expectTypeOf>().toEqualTypeOf< + "body" | "createdAt" | "id" | "title" | "updatedAt" | "version" + >(); + }); + }); + + describe("write input", () => { + it("never exposes the version column", () => { + expectTypeOf>().not.toHaveProperty( + "version", + ); + expectTypeOf>().not.toHaveProperty( + "version", + ); + + assertType>({ + title: "Hello", + // @ts-expect-error - the version moves with the write, never in it + version: 2, + }); + }); + }); + + // The R1 case from the Stage 4 plan: the reserved-name check has to resolve + // `TEditorial` before `TFields` is checked against its constraint. The runtime + // `assertFieldName` guard covers a JavaScript caller either way, but this is + // what makes the mistake visible in the editor. + describe("reserved field names", () => { + it("rejects `version` once editorial is enabled", () => { + defineContentType({ + id: "test.clash-version", + tableName: "test_clash_version", + fields: { + title: field.text({ required: true }), + // @ts-expect-error - generated by `editorial` + version: field.number({ integer: true, defaultValue: 0 }), + }, + editorial: { enabled: true }, + admin: { label: { plural: "Clashes", singular: "Clash" } }, + }); + }); + + it("still allows it without editorial", () => { + const withOwnVersion = defineContentType({ + id: "test.own-version", + tableName: "test_own_version", + fields: { + title: field.text({ required: true }), + version: field.number({ integer: true, defaultValue: 0 }), + }, + admin: { label: { plural: "Fine", singular: "Fine" } }, + }); + + expectTypeOf(withOwnVersion.editorial.enabled).toEqualTypeOf(); + // Its own declared field, so it is writable - unlike the generated column. + expectTypeOf< + ContentUpdateInput["version"] + >().toEqualTypeOf(); + }); + }); + + describe("admin config", () => { + it("accepts the generated column once enabled", () => { + defineContentType({ + id: "test.version-column", + tableName: "test_version_column", + fields: { title: field.text({ required: true }) }, + editorial: { enabled: true }, + admin: { + label: { plural: "Columns", singular: "Column" }, + list: { columns: ["title", "version"], defaultOrderBy: "version" }, + }, + }); + }); + + it("rejects it when editorial is off", () => { + defineContentType({ + id: "test.no-version-column", + tableName: "test_no_version_column", + fields: { title: field.text({ required: true }) }, + admin: { + label: { plural: "Columns", singular: "Column" }, + // @ts-expect-error - `version` is not a column of this content type + list: { columns: ["title", "version"] }, + }, + }); + }); + }); + + describe("derived type aliases", () => { + it("adds the generated column to the orderable union", () => { + expectTypeOf>().toEqualTypeOf< + "body" | "createdAt" | "id" | "title" | "updatedAt" | "version" + >(); + }); + + it("leaves the union alone without editorial", () => { + expectTypeOf< + ContentOrderableFieldName + >().toEqualTypeOf<"createdAt" | "id" | "title" | "updatedAt">(); + }); + }); +}); + +describe("the events an editorial content type emits", () => { + type PostEvents = ContentEventsFor; + type NoteEvents = ContentEventsFor; + type PlainEvents = ContentEventsFor; + + it("adds `restored` to any editorial content type", () => { + expectTypeOf().toHaveProperty( + "content.test.editorial.restored", + ); + expectTypeOf().toHaveProperty("content.test.note.restored"); + }); + + it("does not add it without editorial", () => { + type PlainKeys = keyof PlainEvents; + + expectTypeOf<"content.test.category.restored">().not.toExtend(); + // The three every content type gets, so the assertion above is not vacuous. + expectTypeOf<"content.test.category.updated">().toExtend(); + }); + + it("adds the schedule pair only with scheduling", () => { + expectTypeOf().toHaveProperty( + "content.test.editorial.scheduled", + ); + expectTypeOf().toHaveProperty( + "content.test.editorial.schedule_cancelled", + ); + }); + + it("withholds it from an editorial type that cannot schedule", () => { + // `test.note` has editorial but no publication, so there is no `status` to + // move and the keys must not exist at all. + type NoteKeys = keyof NoteEvents; + + expectTypeOf<"content.test.note.scheduled">().not.toExtend(); + expectTypeOf<"content.test.note.schedule_cancelled">().not.toExtend(); + // The one it *does* get, so the assertion above is not vacuous. + expectTypeOf<"content.test.note.restored">().toExtend(); + }); + + it("carries who booked a schedule that fired", () => { + expectTypeOf< + PostEvents["content.test.editorial.published"]["scheduledBy"] + >().toEqualTypeOf(); + }); +}); diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index fa056e1e4..7964f01a8 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -1,3 +1,5 @@ +import type { ContentScheduleCode } from "./schedules"; + /** * Thrown while a content type definition is being built or registered - always * at import/boot time, never per request. The message names the offending @@ -41,3 +43,96 @@ export class ContentInputError extends ContentEngineError { this.name = "ContentInputError"; } } + +/** + * A write lost the race: the record moved between the read the editor started + * from and the write they just sent. + * + * Per-request like {@link ContentInputError}, and carries both versions rather + * than only a message - the AdminCP reloads the newer row and shows what + * changed, which it cannot do from prose. The generated routes turn it into a + * structured 409; nothing from the driver is in it. + */ +export class ContentVersionConflict extends ContentEngineError { + constructor({ + contentTypeId, + currentVersion, + expectedVersion, + itemId, + }: { + contentTypeId: string; + currentVersion: number; + expectedVersion: number; + itemId: number; + }) { + super( + `This record is at version ${currentVersion}, not ${expectedVersion}. Someone else saved it first.`, + { contentTypeId }, + ); + + this.name = "ContentVersionConflict"; + this.currentVersion = currentVersion; + this.expectedVersion = expectedVersion; + this.itemId = itemId; + } + + readonly currentVersion: number; + readonly expectedVersion: number; + readonly itemId: number; +} + +/** + * A revision that cannot be applied to the record as it stands today. + * + * Thrown before anything is written, so a restore is all or nothing. `fields` + * names the content type's own fields and nothing else - never a Zod issue + * tree, which would leak internal paths. + */ +export class ContentRevisionNotRestorable extends ContentEngineError { + constructor({ + contentTypeId, + fields, + revisionId, + }: { + contentTypeId: string; + fields: string[]; + revisionId: number; + }) { + super( + `Revision ${revisionId} cannot be restored: ${fields.join(", ")} ${fields.length === 1 ? "is" : "are"} no longer valid for this content type.`, + { contentTypeId }, + ); + + this.name = "ContentRevisionNotRestorable"; + this.fields = fields; + this.revisionId = revisionId; + } + + readonly fields: string[]; + readonly revisionId: number; +} + +/** + * A schedule that does not make sense: a time already past, or an unpublish + * that would fire before the publish it is meant to follow. + * + * Carries a `code` rather than only prose, because the AdminCP shows a + * different message - and points at a different field - for each one, and + * because the same rule runs client-side before the round trip. + */ +export class ContentScheduleError extends ContentEngineError { + constructor( + message: string, + { + code, + contentTypeId, + }: { code: ContentScheduleCode; contentTypeId: string }, + ) { + super(message, { contentTypeId }); + + this.name = "ContentScheduleError"; + this.code = code; + } + + readonly code: ContentScheduleCode; +} diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts index be4cf2794..295d84a8b 100644 --- a/packages/vitnode/src/content/events.ts +++ b/packages/vitnode/src/content/events.ts @@ -1,7 +1,14 @@ import type { ContentFieldName } from "./types"; export type ContentEventAction = - "created" | "deleted" | "published" | "unpublished" | "updated"; + | "created" + | "deleted" + | "published" + | "restored" + | "schedule_cancelled" + | "scheduled" + | "unpublished" + | "updated"; export interface ContentCreatedPayload { contentId: number; @@ -20,10 +27,82 @@ export interface ContentPublishedPayload { contentId: number; /** When the row was published for the *first* time; never rewritten. */ publishedAt: Date; + /** + * The person who created the schedule that fired this, when one did. + * + * Absent on an interactive publish, so no existing listener sees a new field. + * It is the only way to answer "the system did it, on whose instruction" - + * the actor of a scheduled run is genuinely the system, and inventing a user + * id there would be a lie in the audit trail. + */ + scheduledBy?: null | number; + /** + * The booking that fired this, when one did - and the idempotency key for a + * listener that must act exactly once. + * + * Scheduled announcements are delivered **at least** once: they run in a + * queue task that retries whenever the event, the search write or a cache + * origin failed, and a retry re-emits an event that may already have been + * received. The id does not change between those attempts, so a listener that + * records "I have handled schedule 55" can safely ignore the second copy. + * + * Absent on an interactive publish, which is emitted once by the route that + * performed it and has no booking to point at. + */ + scheduleId?: number; } export interface ContentUnpublishedPayload { contentId: number; + /** As on `published`: who scheduled it, when a schedule fired it. */ + scheduledBy?: null | number; + /** As on `published`: the booking, and the idempotency key for retries. */ + scheduleId?: number; +} + +/** + * A record was rolled back to the field values of an earlier revision. + * + * Emitted **instead of** `updated`, not alongside it - the one-event-per-mutation + * rule below holds here too, and a listener that fired twice would do every + * piece of downstream work twice. `changedFields` is carried for exactly that + * reason: porting an `updated` listener is a rename, not a rewrite. + * + * There is deliberately no publication field. A restore never moves `status` or + * `publishedAt`, so anyone listening for a visibility change still only has to + * watch `published` and `unpublished`. + */ +export interface ContentRestoredPayload { + changedFields: ContentFieldName[]; + contentId: number; + /** The revision the values came from. */ + restoredFromRevisionId: number; + /** The revision this restore itself created. */ + revisionId: number; + version: number; +} + +/** + * A transition was booked for later, or the booking was called off. + * + * These are **not** revisions and consume no version: scheduling changes no + * field value. When the schedule actually fires, the resulting transition emits + * the ordinary `published`/`unpublished` event with `scheduledBy` set. + */ +export interface ContentScheduledPayload { + action: "publish" | "unpublish"; + /** The staff member who booked it. */ + actorUserId: null | number; + contentId: number; + scheduledFor: Date; + scheduleId: number; +} + +export interface ContentScheduleCancelledPayload { + action: "publish" | "unpublish"; + actorUserId: null | number; + contentId: number; + scheduleId: number; } /** @@ -46,6 +125,30 @@ type ContentPublicationEventsFor = > : Record; +/** + * The extra event the editorial workflow adds. + * + * Gated the same way the publication pair is, so a content type without + * `editorial` gains no key at all and a listener for one cannot be registered. + */ +type ContentEditorialEventsFor = + (TDefinition extends { editorial: { enabled: true } } + ? Record< + `content.${TDefinition["id"]}.restored`, + ContentRestoredPayload + > + : Record) & + (TDefinition extends { editorial: { scheduling: { enabled: true } } } + ? Record< + `content.${TDefinition["id"]}.schedule_cancelled`, + ContentScheduleCancelledPayload + > & + Record< + `content.${TDefinition["id"]}.scheduled`, + ContentScheduledPayload + > + : Record); + /** * The events a content type emits, as a literal-keyed map. * @@ -65,7 +168,8 @@ type ContentPublicationEventsFor = * payloads stay minimal. */ export type ContentEventsFor = - ContentPublicationEventsFor & + ContentEditorialEventsFor & + ContentPublicationEventsFor & Record<`content.${TDefinition["id"]}.created`, ContentCreatedPayload> & Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> & Record< diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 679aae900..4ddfab80f 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -33,14 +33,37 @@ export { contentPublicSlugTag, isContentPubliclyVisible, } from "./cache"; -export type { ContentInvalidationInput } from "./cache"; +export type { + ContentInvalidationInput, + ContentInvalidationMode, +} from "./cache"; +export { + parseContentConflict, + parseContentUnprocessable, + zodContentConflict, + zodContentUnprocessable, +} from "./conflicts"; +export type { + ContentConflict, + ContentConflictCode, + ContentUnprocessable, + ContentUnprocessableCode, +} from "./conflicts"; export { + CONTENT_ACTOR_TYPES, CONTENT_CACHE_TAG_MAX_LENGTH, + CONTENT_CONFLICT_CODES, CONTENT_DEFAULT_PAGE_SIZE, + CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS, + CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, + CONTENT_PREVIEW_MAX_TTL_MINUTES, + CONTENT_PREVIEW_MIN_TTL_MINUTES, + CONTENT_PREVIEW_PATH_MAX_LENGTH, + CONTENT_PREVIEW_TOKEN_PLACEHOLDER, CONTENT_PUBLIC_ALWAYS_ORDERABLE, CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, CONTENT_PUBLIC_EXPOSABLE_COLUMNS, @@ -52,6 +75,11 @@ export { CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUS_LENGTH, CONTENT_PUBLICATION_STATUSES, + CONTENT_REVISION_DEFAULT_RETENTION, + CONTENT_REVISION_MAX_RETENTION, + CONTENT_REVISION_MIN_RETENTION, + CONTENT_REVISION_OPERATIONS, + CONTENT_REVISION_SNAPSHOT_VERSION, CONTENT_SEARCH_DESCRIPTION_KINDS, CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH, CONTENT_SEARCH_PATH_MAX_LENGTH, @@ -61,11 +89,17 @@ export { CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_SYSTEM_FIELDS, CONTENT_TEXT_DEFAULT_LENGTH, + CONTENT_UNPROCESSABLE_CODES, isContentPublicationStatus, RESERVED_FILTER_KEYS, } from "./const"; export { defineContentType } from "./define"; -export { ContentEngineError, ContentInputError } from "./errors"; +export { + ContentEngineError, + ContentInputError, + ContentRevisionNotRestorable, + ContentVersionConflict, +} from "./errors"; export { contentEventName } from "./events"; export type { ContentCreatedPayload, @@ -91,6 +125,24 @@ export { withContentPermissions, } from "./registry"; export type { RegisteredContentType } from "./registry"; +export { contentRevisionDiff } from "./revisions"; +export type { + ContentActor, + ContentActorType, + ContentRevisionDetail, + ContentRevisionDiffEntry, + ContentRevisionMeta, + ContentRevisionOperation, + ContentRevisionSnapshot, + ContentSnapshotValue, +} from "./revisions"; +export { contentScheduleTimingError } from "./schedules"; +export type { + ContentSchedule, + ContentScheduleAction, + ContentScheduleCode, + ContentScheduleStatus, +} from "./schedules"; export { buildContentSchemas } from "./schemas"; export type { ContentSchemas } from "./schemas"; export { @@ -107,6 +159,12 @@ export type { ContentBooleanField, ContentCreateInput, ContentDateTimeField, + ContentEditorialConfig, + ContentEditorialEnabled, + ContentEditorialField, + ContentEditorialPreviewConfig, + ContentEditorialRevisionsConfig, + ContentEditorialSchedulingConfig, ContentEnumField, ContentFieldDescriptor, ContentFieldInput, @@ -120,6 +178,7 @@ export type { ContentNumberField, ContentOnDelete, ContentOrderableFieldName, + ContentPreviewEnabled, ContentPublicApiConfig, ContentPublicationConfig, ContentPublicationField, @@ -134,6 +193,7 @@ export type { ContentReferenceField, ContentReferenceFieldName, ContentRelationField, + ContentSchedulingEnabled, ContentSearchConfig, ContentSearchDescriptionField, ContentSearchTextField, @@ -147,12 +207,16 @@ export type { ContentTypeDefinition, ContentUpdateInput, ContentUserField, + EditorialContentTypeDefinition, FilterableContentFieldKind, FilterableContentFieldName, + PreviewableContentTypeDefinition, ResolvedContentAdminConfig, + ResolvedContentEditorialConfig, ResolvedContentIndex, ResolvedContentPublicApiConfig, ResolvedContentPublicationConfig, ResolvedContentSearchConfig, + SchedulableContentTypeDefinition, SearchableContentTypeDefinition, } from "./types"; diff --git a/packages/vitnode/src/content/next/fetch.server.ts b/packages/vitnode/src/content/next/fetch.server.ts index ac437025d..d17225d1f 100644 --- a/packages/vitnode/src/content/next/fetch.server.ts +++ b/packages/vitnode/src/content/next/fetch.server.ts @@ -3,6 +3,7 @@ import type { z } from "zod"; import type { AnyContentTypeDefinition, + PreviewableContentTypeDefinition, PublicContentTypeDefinition, } from "../types"; @@ -91,6 +92,64 @@ export const contentPublicFetch = async ({ : { status: response.status }; }; +/** + * Reads a record through a preview link, from a server component. + * + * The mirror image of {@link contentPublicFetch}, and deliberately so: this one + * opts *out* of the cache and carries no tags at all. + * + * - **`cache: "no-store"`.** A preview is an unpublished record behind a + * short-lived credential. Storing one would keep a draft readable after the + * token expired, and would serve one reviewer's link to the next visitor. + * - **No tags.** There is nothing to invalidate: the response was never stored, + * and a preview is a point-in-time read of one frozen revision. + * + * The route answers 404 for every kind of bad token, so a caller gets one + * status to handle rather than a taxonomy - `notFound()` is the whole error + * path. + * + * ```tsx title="src/app/articles/preview/[token]/page.tsx" + * const { data } = await contentPreviewFetch({ + * definition: articleContentType, + * pluginId: "@vitnode/example", + * token: (await params).token, + * }); + * if (!data) notFound(); + * ``` + */ +export const contentPreviewFetch = async ({ + definition, + pluginId, + schema, + token, +}: { + definition: PreviewableContentTypeDefinition; + pluginId: string; + schema?: TSchema; + token: string; +}): Promise>> => { + const response = await rawApiFetch({ + method: "get", + module: `content/${definition.publicApi.path}`, + options: { cache: "no-store" }, + path: `/preview/${encodeURIComponent(token)}`, + pluginId, + }); + + 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, diff --git a/packages/vitnode/src/content/next/index.ts b/packages/vitnode/src/content/next/index.ts index f01f56e7e..c918315b6 100644 --- a/packages/vitnode/src/content/next/index.ts +++ b/packages/vitnode/src/content/next/index.ts @@ -8,7 +8,15 @@ * * The cache *tags* live in `@vitnode/core/content`, because they are strings. */ -export { contentPublicFetch, contentPublicItemTags } from "./fetch.server"; +export { + contentPreviewFetch, + contentPublicFetch, + contentPublicItemTags, +} from "./fetch.server"; export type { ContentPublicFetchResult } from "./fetch.server"; +export { POST as contentRevalidateRoute } from "./revalidate-route.server"; export { revalidateContent } from "./revalidate.server"; -export type { ContentInvalidationMode } from "./revalidate.server"; +export type { + ContentInvalidationContext, + ContentInvalidationMode, +} from "./revalidate.server"; diff --git a/packages/vitnode/src/content/next/revalidate-route.server.test.ts b/packages/vitnode/src/content/next/revalidate-route.server.test.ts new file mode 100644 index 000000000..bbfadac94 --- /dev/null +++ b/packages/vitnode/src/content/next/revalidate-route.server.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CONTENT_REVALIDATE_TIMESTAMP_HEADER } from "../server/revalidate-bridge"; + +interface CacheCall { + 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({ profile, tag }); + }, + updateTag: () => { + throw new Error("updateTag is Server-Action-only"); + }, +})); + +const { POST } = await import("./revalidate-route.server"); + +const SECRET = "shared-secret"; + +const body = { + contentTypeId: "example.article", + id: 7, + isPublic: true, + mode: "immediate" as const, + slugs: ["hello-world"], + wasPublic: false, +}; + +const request = (overrides?: { + body?: string; + secret?: string; + timestamp?: number | string; +}) => + new Request("https://web.example.com/api/vitnode/content/revalidate", { + body: overrides?.body ?? JSON.stringify(body), + headers: { + authorization: `Bearer ${overrides?.secret ?? SECRET}`, + "content-type": "application/json", + [CONTENT_REVALIDATE_TIMESTAMP_HEADER]: String( + overrides?.timestamp ?? Date.now(), + ), + }, + method: "POST", + }); + +beforeEach(() => { + calls.length = 0; + vi.stubEnv("CRON_SECRET", SECRET); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("the revalidation Route Handler", () => { + it("expires the tags a valid request names", async () => { + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(calls.length).toBeGreaterThan(0); + // `updateTag` throws in this mock, so reaching here at all proves the + // handler picked the Route-Handler path. + expect(calls.every(call => call.profile !== undefined)).toBe(true); + }); + + it("expires the list, the item and the slug", async () => { + await POST(request()); + + expect(calls.map(call => call.tag).sort()).toEqual([ + "content:example.article:item:7", + "content:example.article:list", + "content:example.article:slug:hello-world", + ]); + }); + + it("honours stale-while-revalidate", async () => { + await POST( + request({ + body: JSON.stringify({ ...body, mode: "stale-while-revalidate" }), + }), + ); + + expect(calls[0].profile).toBe("max"); + }); + + it.each([ + ["the wrong secret", { secret: "not-the-secret" }], + ["a secret of a different length", { secret: "short" }], + ])("refuses %s", async (_name, overrides) => { + const response = await POST(request(overrides)); + + expect(response.status).toBe(403); + expect(calls).toHaveLength(0); + }); + + it("refuses a missing bearer token", async () => { + const response = await POST( + new Request("https://web.example.com/x", { + body: JSON.stringify(body), + method: "POST", + }), + ); + + expect(response.status).toBe(403); + }); + + it.each([ + ["stale", Date.now() - 10 * 60 * 1000], + ["from the future", Date.now() + 10 * 60 * 1000], + ["not a number", "yesterday"], + ])("refuses a timestamp that is %s", async (_name, timestamp) => { + const response = await POST(request({ timestamp })); + + expect(response.status).toBe(403); + expect(calls).toHaveLength(0); + }); + + it.each([ + ["not JSON", "not json at all"], + ["the wrong shape", JSON.stringify({ nope: true })], + ["a bad mode", JSON.stringify({ ...body, mode: "eventually" })], + ])("answers 400 for a body that is %s", async (_name, payload) => { + const response = await POST(request({ body: payload })); + + expect(response.status).toBe(400); + expect(calls).toHaveLength(0); + }); +}); diff --git a/packages/vitnode/src/content/next/revalidate-route.server.ts b/packages/vitnode/src/content/next/revalidate-route.server.ts new file mode 100644 index 000000000..2a22e6b28 --- /dev/null +++ b/packages/vitnode/src/content/next/revalidate-route.server.ts @@ -0,0 +1,89 @@ +import "server-only"; +import crypto from "node:crypto"; +import { z } from "zod"; + +import { CONFIG } from "../../lib/config"; +import { + CONTENT_REVALIDATE_MAX_SKEW_MS, + CONTENT_REVALIDATE_TIMESTAMP_HEADER, +} from "../server/revalidate-bridge"; +import { revalidateContent } from "./revalidate.server"; + +const zodBody = z.object({ + contentTypeId: z.string().min(1), + id: z.number().int().positive(), + isPublic: z.boolean(), + mode: z.enum(["immediate", "stale-while-revalidate"]), + slugs: z.array(z.string()), + wasPublic: z.boolean(), +}); + +const matches = (provided: string, expected: string): boolean => { + const a = Buffer.from(provided, "utf8"); + const b = Buffer.from(expected, "utf8"); + + // `timingSafeEqual` throws on a length mismatch, so the length check is not + // optional. It leaks the length of a secret and nothing else. + return a.length === b.length && crypto.timingSafeEqual(a, b); +}; + +/** + * The web-side half of the background cache bridge. + * + * Mount it once per app: + * + * ```ts title="src/app/api/vitnode/content/revalidate/route.ts" + * export { POST } from "@vitnode/core/content/next/revalidate-route"; + * ``` + * + * It exists because the API process cannot call `next/cache`. When a scheduled + * publish makes a record public, *something* has to expire the tags in the + * process that owns the cache - and this is the smallest thing that can. + * + * Deliberately narrow. It takes one shape, it calls one function, and the worst + * a valid request can do is expire a cache tag. It is not an events endpoint + * and must not grow into one. + */ +export const POST = async (request: Request): Promise => { + const secret = CONFIG.cronJobSecret; + const authorization = request.headers.get("authorization") ?? ""; + const provided = authorization.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : ""; + + if (!provided || !matches(provided, secret)) { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + + // Replaying a revalidation only expires a tag again, so a window is + // proportionate - a nonce store would be a database table to guard nothing. + const timestamp = Number( + request.headers.get(CONTENT_REVALIDATE_TIMESTAMP_HEADER), + ); + if ( + !Number.isFinite(timestamp) || + Math.abs(Date.now() - timestamp) > CONTENT_REVALIDATE_MAX_SKEW_MS + ) { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: "Invalid body" }, { status: 400 }); + } + + const parsed = zodBody.safeParse(payload); + if (!parsed.success) { + return Response.json({ error: "Invalid body" }, { status: 400 }); + } + + const { mode, ...input } = parsed.data; + + // `route-handler`, truthfully: `updateTag` throws here, and saying otherwise + // would turn every background publish into a 500. + revalidateContent(input, { context: "route-handler", mode }); + + return Response.json({ ok: true }); +}; diff --git a/packages/vitnode/src/content/next/revalidate.server.test.ts b/packages/vitnode/src/content/next/revalidate.server.test.ts index cbf537078..825b9afd0 100644 --- a/packages/vitnode/src/content/next/revalidate.server.test.ts +++ b/packages/vitnode/src/content/next/revalidate.server.test.ts @@ -88,3 +88,48 @@ describe("what it touches", () => { expect(calls.map(call => call.tag)).toContain("content:test.post:slug:new"); }); }); + +describe("context", () => { + it("uses updateTag from a Server Action, for read-your-own-writes", () => { + revalidateContent(published, { + context: "server-action", + mode: "immediate", + }); + + expect(functionsCalled()).toEqual(["updateTag"]); + }); + + it("expires with `expire: 0` from a Route Handler", () => { + // `updateTag` throws outside a Server Action, so the background cache + // bridge - which lands in a Route Handler - would turn every scheduled + // publish into a 500 if it used the default. + revalidateContent(published, { + context: "route-handler", + mode: "immediate", + }); + + expect(functionsCalled()).toEqual(["revalidateTag"]); + expect(calls.every(call => call.profile !== undefined)).toBe(true); + expect(calls[0].profile).toEqual({ expire: 0 }); + }); + + it("leaves stale-while-revalidate alone in either context", () => { + // SWR already works everywhere, so the context changes nothing. + for (const context of ["route-handler", "server-action"] as const) { + calls.length = 0; + revalidateContent(published, { + context, + mode: "stale-while-revalidate", + }); + + expect(functionsCalled()).toEqual(["revalidateTag"]); + expect(calls[0].profile).toBe("max"); + } + }); + + it("defaults to server-action, so nothing existing changed", () => { + revalidateContent(published, { mode: "immediate" }); + + expect(functionsCalled()).toEqual(["updateTag"]); + }); +}); diff --git a/packages/vitnode/src/content/next/revalidate.server.ts b/packages/vitnode/src/content/next/revalidate.server.ts index 9ac72f961..c766c9b95 100644 --- a/packages/vitnode/src/content/next/revalidate.server.ts +++ b/packages/vitnode/src/content/next/revalidate.server.ts @@ -1,21 +1,25 @@ import "server-only"; import { revalidateTag, updateTag } from "next/cache"; -import type { ContentInvalidationInput } from "../cache"; +import type { + ContentInvalidationInput, + ContentInvalidationMode, +} from "../cache"; import { contentInvalidationTags } from "../cache"; +export type { ContentInvalidationMode }; + /** - * How hard a mutation expires the tags it touched. + * Where the call is coming from, which decides *how* `immediate` is done. * - * - `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. + * `updateTag` buys read-your-own-writes and is Server-Action-only. A Route + * Handler cannot call it - but `revalidateTag(tag, { expire: 0 })` expires a + * tag immediately there, which is the documented path for a webhook. Same + * guarantee for the next reader either way, so the caller names its context and + * gets the strongest option available to it. */ -export type ContentInvalidationMode = "immediate" | "stale-while-revalidate"; +export type ContentInvalidationContext = "route-handler" | "server-action"; /** * Expires the public cache entries one mutation actually affected. @@ -39,22 +43,35 @@ export type ContentInvalidationMode = "immediate" | "stale-while-revalidate"; * 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`. + * `context` defaults to `server-action`, which is where every generated write + * path already lives. Background work reaches this through the + * [revalidation bridge](../server/revalidate-bridge.ts), which lands in a Route + * Handler and says so. */ export const revalidateContent = ( input: ContentInvalidationInput, - options?: { mode?: ContentInvalidationMode }, + options?: { + context?: ContentInvalidationContext; + mode?: ContentInvalidationMode; + }, ): void => { const mode = options?.mode ?? "immediate"; + const context = options?.context ?? "server-action"; for (const tag of contentInvalidationTags(input)) { - if (mode === "immediate") { + if (mode !== "immediate") { + revalidateTag(tag, "max"); + continue; + } + + if (context === "server-action") { updateTag(tag); continue; } - revalidateTag(tag, "max"); + // `updateTag` throws outside a Server Action. `expire: 0` is the documented + // equivalent for a webhook: the entry is expired now rather than served + // stale once more. + revalidateTag(tag, { expire: 0 }); } }; diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index a6ea9bfe7..dcfadce95 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -6,6 +6,7 @@ import type { import type { AnyContentTypeDefinition } from "./types"; import { + CONTENT_EDITORIAL_FIELDS, CONTENT_PERMISSIONS, CONTENT_PUBLICATION_FIELDS, CONTENT_SYSTEM_FIELDS, @@ -197,6 +198,18 @@ export const contentPermissionEntries = ( }, ] : []), + // Restoring is the one generated operation that rewrites many fields at once + // from a source the editor did not type, so it gets its own gate. It depends + // on `can_edit` rather than `can_view`: somebody who may not edit must not + // reach the same outcome through the history. + ...(definition?.editorial.enabled + ? [ + { + dependsOn: [CONTENT_PERMISSIONS.edit], + permission: CONTENT_PERMISSIONS.restore, + }, + ] + : []), ]; /** @@ -231,6 +244,7 @@ export const orderableColumns = ( ...definition.admin.list.orderableFields, ...CONTENT_SYSTEM_FIELDS, ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), + ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), ]; /** diff --git a/packages/vitnode/src/content/revisions.test.ts b/packages/vitnode/src/content/revisions.test.ts new file mode 100644 index 000000000..878486c12 --- /dev/null +++ b/packages/vitnode/src/content/revisions.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { ContentRevisionSnapshot } from "./revisions"; + +import { contentRevisionDiff } from "./revisions"; + +const snapshot = ( + fields: ContentRevisionSnapshot["fields"], +): ContentRevisionSnapshot => ({ + contentTypeId: "test.editorial", + createdAt: "2024-01-01T00:00:00.000Z", + fields, + id: 1, + schemaVersion: 1, + updatedAt: "2024-01-01T00:00:00.000Z", + version: 1, +}); + +const names = ["title", "excerpt", "views", "featured", "publishedOn"]; + +describe("contentRevisionDiff", () => { + it("reports only the fields that moved", () => { + const before = snapshot({ excerpt: "Old", title: "Hello", views: 1 }); + const after = snapshot({ excerpt: "Old", title: "Goodbye", views: 2 }); + + expect(contentRevisionDiff(names, before, after)).toEqual([ + { after: "Goodbye", before: "Hello", name: "title" }, + { after: 2, before: 1, name: "views" }, + ]); + }); + + it("keeps the content type's declaration order", () => { + const before = snapshot({ excerpt: "a", title: "a", views: 1 }); + const after = snapshot({ excerpt: "b", title: "b", views: 2 }); + + expect(contentRevisionDiff(names, before, after).map(e => e.name)).toEqual([ + "title", + "excerpt", + "views", + ]); + }); + + it("distinguishes an explicit null from an absent field", () => { + const before = snapshot({ excerpt: "Old", title: "Hello" }); + const after = snapshot({ excerpt: null, title: "Hello" }); + + expect(contentRevisionDiff(names, before, after)).toEqual([ + { after: null, before: "Old", name: "excerpt" }, + ]); + }); + + it("ignores a field neither snapshot carries", () => { + const before = snapshot({ title: "Hello" }); + const after = snapshot({ title: "Hello" }); + + expect(contentRevisionDiff(names, before, after)).toEqual([]); + }); + + it("skips a field the content type no longer declares", () => { + // Present in both snapshots, absent from `names` - it is history, not a + // change, and showing it would invite a restore that cannot happen. + const before = snapshot({ sinceRemoved: "a", title: "Hello" }); + const after = snapshot({ sinceRemoved: "b", title: "Hello" }); + + expect(contentRevisionDiff(names, before, after)).toEqual([]); + }); + + it("treats a create as every field being new", () => { + const after = snapshot({ excerpt: null, title: "Hello", views: 0 }); + + // No previous revision, so nothing is compared away. + expect(contentRevisionDiff(names, null, after)).toEqual([ + { after: "Hello", before: undefined, name: "title" }, + { after: null, before: undefined, name: "excerpt" }, + { after: 0, before: undefined, name: "views" }, + { after: undefined, before: undefined, name: "featured" }, + { after: undefined, before: undefined, name: "publishedOn" }, + ]); + }); + + it("handles booleans and zero without treating them as absent", () => { + const before = snapshot({ featured: true, views: 0 }); + const after = snapshot({ featured: false, views: 0 }); + + expect(contentRevisionDiff(names, before, after)).toEqual([ + { after: false, before: true, name: "featured" }, + ]); + }); +}); diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts new file mode 100644 index 000000000..a9b02057c --- /dev/null +++ b/packages/vitnode/src/content/revisions.ts @@ -0,0 +1,109 @@ +import type { CONTENT_ACTOR_TYPES, CONTENT_REVISION_OPERATIONS } from "./const"; + +export type ContentRevisionOperation = + (typeof CONTENT_REVISION_OPERATIONS)[number]; + +export type ContentActorType = (typeof CONTENT_ACTOR_TYPES)[number]; + +/** + * Who performed a mutation. + * + * A plain value object, not something read off a request: the editorial service + * takes one as an argument, so a route builds it from the Hono context and a + * queue handler hands over `{ type: "system", userId: null }` without either of + * them depending on the other's world. + */ +export interface ContentActor { + type: ContentActorType; + userId: null | number; +} + +/** + * A value as it is stored in a snapshot. + * + * Deliberately narrow: a `Date` becomes an ISO string, a relation or user + * becomes the foreign key it already is, and nothing else survives. There is no + * runtime class instance in a snapshot, so re-reading one years later needs + * nothing but `JSON.parse`. + */ +export type ContentSnapshotValue = boolean | null | number | string; + +/** + * The complete post-mutation editable state of one record. + * + * Complete rather than a patch: restoring from a patch means replaying every + * revision since, which turns a single read into a fold that gets slower the + * longer the history is - and produces nothing if one link was pruned. + * + * What is deliberately absent is as important as what is here. No relation + * *labels* (they are administrative metadata belonging to another content type, + * which may not publish them at all), no search document, no cache tags, + * nothing derived. + */ +export interface ContentRevisionSnapshot { + contentTypeId: string; + createdAt: string; + /** Every declared field, by name. */ + fields: Record; + id: number; + /** Present only for a content type with the publication lifecycle. */ + publication?: { publishedAt: null | string; status: string }; + schemaVersion: number; + updatedAt: string; + version: number; +} + +/** One revision as the history list shows it - metadata, never the snapshot. */ +export interface ContentRevisionMeta { + /** Display name of the actor, or `null` for a system mutation. */ + actorName: null | string; + actorType: ContentActorType; + actorUserId: null | number; + changedFields: string[]; + createdAt: Date | string; + id: number; + operation: ContentRevisionOperation; + restoredFromRevisionId: null | number; + version: number; +} + +/** One revision with its snapshot, loaded on demand. */ +export interface ContentRevisionDetail extends ContentRevisionMeta { + snapshot: ContentRevisionSnapshot; +} + +export interface ContentRevisionDiffEntry { + after: ContentSnapshotValue | undefined; + before: ContentSnapshotValue | undefined; + name: string; +} + +/** + * Field-level difference between two snapshots, in declaration order. + * + * Walks `names` - the content type's *current* field list - rather than the + * union of both snapshots' keys, so a field that has since been removed does + * not show up as "changed to nothing". The same projection the restore path + * applies, and for the same reason. + * + * `undefined` on either side means "this snapshot never carried the field", + * which the UI renders differently from an explicit `null`. + */ +export const contentRevisionDiff = ( + names: readonly string[], + before: ContentRevisionSnapshot | null, + after: ContentRevisionSnapshot, +): ContentRevisionDiffEntry[] => { + const entries: ContentRevisionDiffEntry[] = []; + + for (const name of names) { + const previous = before?.fields[name]; + const next = after.fields[name]; + + if (before !== null && previous === next) continue; + + entries.push({ after: next, before: previous, name }); + } + + return entries; +}; diff --git a/packages/vitnode/src/content/schedules.test.ts b/packages/vitnode/src/content/schedules.test.ts new file mode 100644 index 000000000..9f777240b --- /dev/null +++ b/packages/vitnode/src/content/schedules.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentScheduleTimingInput } from "./schedules"; + +import { CONTENT_SCHEDULE_PAST_TOLERANCE_MS } from "./const"; +import { contentScheduleTimingError } from "./schedules"; + +const NOW = new Date("2026-08-05T10:00:00.000Z"); + +const check = (overrides: Partial) => + contentScheduleTimingError({ + action: "publish", + now: NOW, + pending: [], + scheduledFor: new Date("2026-08-05T12:00:00.000Z"), + ...overrides, + }); + +describe("contentScheduleTimingError", () => { + it("accepts a future time", () => { + expect(check({})).toBeNull(); + }); + + it("rejects a time well in the past", () => { + expect(check({ scheduledFor: new Date("2026-08-05T09:00:00.000Z") })).toBe( + "CONTENT_SCHEDULE_IN_PAST", + ); + }); + + it("accepts a time just barely in the past", () => { + // A browser clock a minute behind the server is ordinary, and one cron tick + // is a minute wide - "now" is what the editor meant, so it is accepted and + // fires on the next tick. + expect( + check({ + scheduledFor: new Date( + NOW.getTime() - CONTENT_SCHEDULE_PAST_TOLERANCE_MS + 1000, + ), + }), + ).toBeNull(); + }); + + it("rejects one just outside the tolerance", () => { + expect( + check({ + scheduledFor: new Date( + NOW.getTime() - CONTENT_SCHEDULE_PAST_TOLERANCE_MS - 1000, + ), + }), + ).toBe("CONTENT_SCHEDULE_IN_PAST"); + }); + + it("rejects an invalid date rather than passing it to the server", () => { + expect(check({ scheduledFor: new Date("nonsense") })).toBe( + "CONTENT_SCHEDULE_IN_PAST", + ); + }); + + describe("ordering against a pending publish", () => { + const pending = [ + { + action: "publish" as const, + scheduledFor: "2026-08-05T12:00:00.000Z", + }, + ]; + + it("accepts an unpublish after it", () => { + expect( + check({ + action: "unpublish", + pending, + scheduledFor: new Date("2026-08-05T13:00:00.000Z"), + }), + ).toBeNull(); + }); + + it("rejects an unpublish before it", () => { + // It would fire against a draft, no-op, and then the record would go live + // afterwards - the opposite of what was asked for. + expect( + check({ + action: "unpublish", + pending, + scheduledFor: new Date("2026-08-05T11:00:00.000Z"), + }), + ).toBe("CONTENT_SCHEDULE_ORDER"); + }); + + it("rejects an unpublish at exactly the same moment", () => { + // Same tick, undefined order. Refusing is the only honest answer. + expect( + check({ + action: "unpublish", + pending, + scheduledFor: new Date("2026-08-05T12:00:00.000Z"), + }), + ).toBe("CONTENT_SCHEDULE_ORDER"); + }); + + it("does not constrain a publish", () => { + // Rescheduling the publish itself is not ordered against its own + // predecessor - the old row is about to be cancelled. + expect( + check({ pending, scheduledFor: new Date("2026-08-05T11:00:00.000Z") }), + ).toBeNull(); + }); + + it("does not constrain an unpublish when no publish is pending", () => { + expect( + check({ + action: "unpublish", + pending: [], + scheduledFor: new Date("2026-08-05T11:00:00.000Z"), + }), + ).toBeNull(); + }); + }); +}); diff --git a/packages/vitnode/src/content/schedules.ts b/packages/vitnode/src/content/schedules.ts new file mode 100644 index 000000000..8959ee612 --- /dev/null +++ b/packages/vitnode/src/content/schedules.ts @@ -0,0 +1,85 @@ +import type { + CONTENT_SCHEDULE_ACTIONS, + CONTENT_SCHEDULE_CODES, + CONTENT_SCHEDULE_STATUSES, +} from "./const"; + +import { CONTENT_SCHEDULE_PAST_TOLERANCE_MS } from "./const"; + +export type ContentScheduleAction = (typeof CONTENT_SCHEDULE_ACTIONS)[number]; + +export type ContentScheduleStatus = (typeof CONTENT_SCHEDULE_STATUSES)[number]; + +export type ContentScheduleCode = + (typeof CONTENT_SCHEDULE_CODES)[keyof typeof CONTENT_SCHEDULE_CODES]; + +/** One schedule, as the AdminCP and the API both see it. */ +export interface ContentSchedule { + action: ContentScheduleAction; + /** Display name of the person who asked for it, when it is resolvable. */ + actorName: null | string; + completedAt: Date | null | string; + createdAt: Date | string; + createdBy: null | number; + /** + * Why the announcements for a *completed* schedule have not gone out yet. + * + * A separate field from `lastError` because it means something different: the + * record really did publish, and what is still being retried is the event, + * the search write and the cache invalidation. + */ + effectsError: null | string; + id: number; + lastError: null | string; + scheduledFor: Date | string; + status: ContentScheduleStatus; +} + +export interface ContentScheduleTimingInput { + action: ContentScheduleAction; + now: Date; + /** The pending schedules already on this record, of any action. */ + pending: { action: ContentScheduleAction; scheduledFor: Date | string }[]; + scheduledFor: Date; +} + +/** + * Whether a requested schedule makes sense, and why not when it does not. + * + * Pure, and shared by the client and the server on purpose: the dialog can + * refuse an impossible date before the round trip, and the route stays the + * authority - both from one function, so they cannot drift into disagreeing. + */ +export const contentScheduleTimingError = ({ + action, + now, + pending, + scheduledFor, +}: ContentScheduleTimingInput): ContentScheduleCode | null => { + if (Number.isNaN(scheduledFor.getTime())) return "CONTENT_SCHEDULE_IN_PAST"; + + // A browser clock a minute behind the server is ordinary, and one cron tick + // is a minute wide - so "just now" is accepted and fires on the next tick. + if ( + scheduledFor.getTime() < + now.getTime() - CONTENT_SCHEDULE_PAST_TOLERANCE_MS + ) { + return "CONTENT_SCHEDULE_IN_PAST"; + } + + if (action === "unpublish") { + const publish = pending.find(entry => entry.action === "publish"); + + // Unpublishing before the publish that has not happened yet would fire + // against a draft, no-op, and then the record would go live afterwards - + // the opposite of what was asked for. + if ( + publish && + new Date(publish.scheduledFor).getTime() >= scheduledFor.getTime() + ) { + return "CONTENT_SCHEDULE_ORDER"; + } + } + + return null; +}; diff --git a/packages/vitnode/src/content/schemas.test.ts b/packages/vitnode/src/content/schemas.test.ts index 0832b2f06..f95ad6b36 100644 --- a/packages/vitnode/src/content/schemas.test.ts +++ b/packages/vitnode/src/content/schemas.test.ts @@ -141,6 +141,55 @@ describe("generated schemas", () => { }); }); + describe("editorial version", () => { + const editorial = defineContentType({ + id: "test.schema-version", + tableName: "test_schema_version", + fields: { title: field.text({ required: true }) }, + editorial: { enabled: true }, + admin: { label: { plural: "Versions", singular: "Version" } }, + }); + + const row = { + createdAt: new Date(), + id: 1, + title: "Hello world", + updatedAt: new Date(), + version: 1, + }; + + it("is part of the response once editorial is enabled", () => { + expect(editorial.schemas.select.safeParse(row).success).toBe(true); + expect(editorial.schemas.selectObject.shape.version).toBeDefined(); + }); + + it("is missing from the response without it", () => { + expect(schemas.selectObject.shape.version).toBeUndefined(); + }); + + it("is never writable", () => { + // Both schemas are strict, so this is a rejection rather than a strip - + // the version moves with the write, never in it. + expect( + editorial.schemas.create.safeParse({ title: "Hello", version: 2 }) + .success, + ).toBe(false); + expect( + editorial.schemas.update.safeParse({ title: "Hello", version: 2 }) + .success, + ).toBe(false); + }); + + it("is orderable once editorial is enabled", () => { + expect( + editorial.schemas.order.safeParse({ orderBy: "version" }).success, + ).toBe(true); + expect(schemas.order.safeParse({ orderBy: "version" }).success).toBe( + false, + ); + }); + }); + describe("order", () => { it("allows the declared orderable fields and the system columns", () => { for (const orderBy of [ diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 384613b05..d4b79fc6f 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -13,6 +13,7 @@ import type { } from "./types"; import { + CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLIC_ALWAYS_ORDERABLE, CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, @@ -74,6 +75,18 @@ export interface ContentSchemas { selectObject: z.ZodObject; /** Request body for update. Every field optional, but never empty. */ update: z.ZodType>; + /** + * Request body for an editorial update: the field values, plus the version + * the editor started from. + * + * An envelope rather than a key inside `values`, because `update` is a strict + * object of *content fields* and `expectedVersion` is transport. Empty for a + * content type without `editorial`, whose update body stays exactly as it was. + */ + updateEnvelope: z.ZodType<{ + expectedVersion: number; + values: ContentUpdateInput; + }>; } const textSchema = (fieldValue: { @@ -287,11 +300,13 @@ const publicSelectShape = ( */ export const buildContentSchemas = ({ admin, + editorial = false, fields, publicApi = DISABLED_PUBLIC_API, publication = false, }: { admin: ResolvedContentAdminConfig; + editorial?: boolean; fields: ContentFieldMap; publicApi?: ResolvedContentPublicApiConfig; publication?: boolean; @@ -307,6 +322,12 @@ export const buildContentSchemas = ({ } : {}; + // Read-only for the same reason, and returned for one: a client needs it to + // send `expectedVersion` back on the next write. + const editorialSelectShape: z.ZodRawShape = editorial + ? { version: z.number().int().positive() } + : {}; + const selectShape: z.ZodRawShape = { id: z.number(), ...Object.fromEntries( @@ -316,6 +337,7 @@ export const buildContentSchemas = ({ ]), ), ...publicationSelectShape, + ...editorialSelectShape, createdAt: z.date(), updatedAt: z.date(), }; @@ -334,6 +356,7 @@ export const buildContentSchemas = ({ ...admin.list.orderableFields, ...CONTENT_SYSTEM_FIELDS, ...(publication ? CONTENT_PUBLICATION_FIELDS : []), + ...(editorial ? CONTENT_EDITORIAL_FIELDS : []), ]; const selectObject = z.object(selectShape); @@ -390,5 +413,14 @@ export const buildContentSchemas = ({ select: selectObject as unknown as z.ZodType>, selectObject, update: update as unknown as z.ZodType>, + updateEnvelope: z.strictObject({ + // Positive, so a client that forgot to send one cannot coerce `0` past + // the guard and race the very check it is meant to lose. + expectedVersion: z.number().int().positive(), + values: update, + }) as unknown as z.ZodType<{ + expectedVersion: number; + values: ContentUpdateInput; + }>, }; }; diff --git a/packages/vitnode/src/content/server/actor.ts b/packages/vitnode/src/content/server/actor.ts new file mode 100644 index 000000000..95af39998 --- /dev/null +++ b/packages/vitnode/src/content/server/actor.ts @@ -0,0 +1,30 @@ +import type { Context } from "hono"; + +import type { ContentActor } from "../revisions"; + +/** + * Who a request is acting as, for the revision it is about to write. + * + * Built by the *route*, not by the service, because only the route knows which + * gate it sits behind. An admin route has already been through + * `globalAdminMiddleware` and `assertStaffPermission`, so `c.get("admin")` is + * populated and the mutation is `staff`. A hand-written route that a signed-in + * member reached is `api`. Anything with no user at all - a cron request, the + * queue worker - is `system`, and gets a `null` user id rather than a fake one. + */ +export const resolveContentActor = (c: Context): ContentActor => { + const admin = c.get("admin") as null | { user?: { id?: unknown } }; + const adminId = admin?.user?.id; + if (typeof adminId === "number") return { type: "staff", userId: adminId }; + + const user = c.get("user") as null | { id?: unknown }; + if (typeof user?.id === "number") return { type: "api", userId: user.id }; + + return { type: "system", userId: null }; +}; + +/** The actor a background task runs as. Spelled out so no call site invents one. */ +export const CONTENT_SYSTEM_ACTOR: ContentActor = { + type: "system", + userId: null, +}; diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index cb951e9d8..8f4465ea2 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -61,6 +61,24 @@ export const buildPublicationColumns = (): Record< .default("draft"), }); +/** + * The one column `editorial: { enabled: true }` adds. + * + * `DEFAULT 1 NOT NULL`, so drizzle-kit backfills an existing table in a single + * statement and every pre-existing row starts at version 1 - the same property + * that makes adding `status DEFAULT 'draft'` safe. + * + * Never written by `create` or `update`: the editorial service increments it in + * the same conditional `UPDATE` that guards on it, which is what makes the + * check-and-set atomic. + */ +export const buildEditorialColumns = (): Record< + string, + PgColumnBuilderBase +> => ({ + version: integer().notNull().default(1), +}); + /** * Applies `NOT NULL` and the column default. * diff --git a/packages/vitnode/src/content/server/editorial-effects.test.ts b/packages/vitnode/src/content/server/editorial-effects.test.ts new file mode 100644 index 000000000..f1c22dd3a --- /dev/null +++ b/packages/vitnode/src/content/server/editorial-effects.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testEditorialPostContentType } from "@/tests/content-fixtures"; + +import type { ContentEditorialOutcome } from "./editorial-service"; + +const syncContentSearch = vi.fn(); + +vi.mock("./search-sync", () => ({ + syncContentSearch: (...args: unknown[]) => syncContentSearch(...args), +})); + +const { contentEditorialEffects } = await import("./editorial-effects"); + +const OWNER = "@vitnode/example"; + +const outcome = ( + overrides: Partial> = {}, +): ContentEditorialOutcome => + ({ + changed: true, + changedFields: [], + operation: "publish", + previousSlug: null, + restoredFromRevisionId: null, + revisionId: 90, + row: { + id: 7, + publishedAt: new Date("2026-08-05T12:00:00.000Z"), + slug: "hello-world", + status: "published", + title: "Hello world", + version: 4, + }, + version: 4, + ...overrides, + }) as unknown as ContentEditorialOutcome; + +const harness = ({ + contextPlugin = "@vitnode/core", + emit = vi.fn().mockResolvedValue({ + delivered: 1, + eventId: "event-1", + failures: [], + status: "delivered", + }), +}: { contextPlugin?: string; emit?: ReturnType } = {}) => { + const store: Record = { + events: { emit }, + plugin: { id: contextPlugin }, + }; + + return { + c: { get: (key: string) => store[key] } as unknown as Context, + emit, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + syncContentSearch.mockResolvedValue({ + action: "upsert", + documentId: "example.post:7", + }); +}); + +describe("contentEditorialEffects", () => { + it("returns what the event transport and the index both reported", async () => { + // Both, because `EventsModel.emit` does not throw: discarding its result + // makes a dead listener look exactly like a delivered one. + const { c } = harness(); + + const result = await contentEditorialEffects( + c, + testEditorialPostContentType, + outcome(), + { pluginId: OWNER }, + ); + + expect(result.event).toMatchObject({ delivered: 1, failures: [] }); + expect(result.search).toMatchObject({ action: "upsert" }); + }); + + it("surfaces a listener failure rather than swallowing it", async () => { + const { c } = harness({ + emit: vi.fn().mockResolvedValue({ + delivered: 0, + eventId: "event-1", + failures: [ + { + error: "Service unavailable", + listener: "send-notification", + module: "notifications", + pluginId: OWNER, + }, + ], + status: "delivered", + }), + }); + + const result = await contentEditorialEffects( + c, + testEditorialPostContentType, + outcome(), + { pluginId: OWNER }, + ); + + expect(result.event?.failures).toHaveLength(1); + }); + + it("still writes the search document when the event failed", async () => { + // Two independent systems, and an interactive mutation has already + // committed by the time either runs. + const { c } = harness({ + emit: vi.fn().mockResolvedValue({ + delivered: 0, + eventId: "event-1", + failures: [ + { + error: "down", + listener: "l", + module: "m", + pluginId: OWNER, + }, + ], + status: "delivered", + }), + }); + + await contentEditorialEffects(c, testEditorialPostContentType, outcome(), { + pluginId: OWNER, + }); + + expect(syncContentSearch).toHaveBeenCalledTimes(1); + }); + + it("credits the content type's owner, not the plugin on the context", async () => { + const { c, emit } = harness({ contextPlugin: "@vitnode/core" }); + + await contentEditorialEffects(c, testEditorialPostContentType, outcome(), { + pluginId: OWNER, + }); + + expect(emit.mock.calls[0][2]).toEqual({ pluginId: OWNER }); + }); + + it("does no work at all for a no-op outcome", async () => { + // A double-clicked publish button transitions nothing, so there is nothing + // to announce and nothing to index. + const { c, emit } = harness(); + + const result = await contentEditorialEffects( + c, + testEditorialPostContentType, + outcome({ changed: false }), + { pluginId: OWNER }, + ); + + expect(result).toEqual({ event: null, search: null }); + expect(emit).not.toHaveBeenCalled(); + expect(syncContentSearch).not.toHaveBeenCalled(); + }); + + describe("the payload", () => { + it("carries no scheduling keys for an interactive mutation", async () => { + // Absent rather than null, so no existing listener sees a new field. + const { c, emit } = harness(); + + await contentEditorialEffects( + c, + testEditorialPostContentType, + outcome(), + { pluginId: OWNER }, + ); + + const payload = emit.mock.calls[0][1] as Record; + expect(payload).not.toHaveProperty("scheduleId"); + expect(payload).not.toHaveProperty("scheduledBy"); + }); + + it("carries the booking and its owner when a schedule fired it", async () => { + const { c, emit } = harness(); + + await contentEditorialEffects( + c, + testEditorialPostContentType, + outcome(), + { pluginId: OWNER, scheduledBy: 3, scheduleId: 55 }, + ); + + expect(emit.mock.calls[0][1]).toMatchObject({ + contentId: 7, + revisionId: 90, + scheduledBy: 3, + scheduleId: 55, + version: 4, + }); + }); + }); +}); diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts new file mode 100644 index 000000000..567ab8933 --- /dev/null +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -0,0 +1,153 @@ +import type { Context } from "hono"; + +import type { EventEmitResult } from "../../api/models/events"; +import type { ContentEventAction } from "../events"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentEditorialOutcome } from "./editorial-service"; +import type { ContentSearchSyncOutcome } from "./search-sync"; + +import { emitContentEvent } from "./emit"; +import { syncContentSearch } from "./search-sync"; + +/** A `delete` has no event action of its own beyond the existing one. */ +const EVENT_ACTION: Record< + ContentEditorialOutcome["operation"], + ContentEventAction +> = { + create: "created", + delete: "deleted", + publish: "published", + restore: "restored", + unpublish: "unpublished", + update: "updated", +}; + +const payloadFor = ( + outcome: ContentEditorialOutcome, + { + scheduledBy, + scheduleId, + }: Pick, +): Record => { + const base = { + contentId: outcome.row.id, + revisionId: outcome.revisionId ?? undefined, + // Both present only when a schedule caused this. A listener that wants to + // know "was this a person, right now?" reads the envelope's actor; these + // answer the different questions of who set it up, possibly weeks ago, and + // which booking this is - the idempotency key for a listener that must act + // once across the effects task's retries. + ...(scheduledBy === undefined ? {} : { scheduledBy }), + ...(scheduleId === undefined ? {} : { scheduleId }), + version: outcome.version, + }; + + switch (outcome.operation) { + case "publish": { + const publishedAt = (outcome.row as { publishedAt?: unknown }) + .publishedAt; + + return publishedAt instanceof Date + ? { ...base, publishedAt } + : { ...base }; + } + case "restore": + return { + ...base, + changedFields: outcome.changedFields, + restoredFromRevisionId: outcome.restoredFromRevisionId, + }; + case "update": + return { ...base, changedFields: outcome.changedFields }; + default: + return base; + } +}; + +export interface ContentEditorialEffectsOptions { + /** The plugin that owns the content type, and therefore the event. */ + pluginId: string; + /** + * The person who created the schedule that caused this, when one did. + * + * `undefined` for an interactive mutation, so the payload is unchanged + * there - the key is absent rather than null, and nothing existing sees a + * new field. + */ + scheduledBy?: null | number; + /** + * The booking that caused this, when one did. Also `undefined` interactively. + * + * This is the identifier a listener uses to make itself idempotent: delivery + * is at-least-once, so the same `published` can arrive twice, but never with + * two different `scheduleId`s for the same booking. + */ + scheduleId?: number; +} + +export interface ContentEditorialEffectsResult { + /** + * What the event transport reported. `null` for a no-op outcome, which emits + * nothing at all. + * + * Present rather than discarded because `EventsModel.emit` does not throw: + * `failures` is the only place a dead listener or a broker outage is visible, + * and a caller that ignores it has decided - explicitly or not - that the + * event is allowed to go missing. + */ + event: EventEmitResult | null; + search: ContentSearchSyncOutcome | null; +} + +/** + * Everything one editorial mutation owes the rest of the system, once its + * transaction has committed. + * + * One function rather than the same four-line block in every route and in the + * queue handler: "which event, and which search operation" is a rule, and a + * rule copied into three places is a rule that will disagree with itself. The + * generated routes call it, and so does the scheduled-publication task. + * + * **Call it only after the write has returned - never inside the transaction.** + * Same rule `syncContentSearch` states for itself, and for the same reason: a + * rollback cannot un-emit an event or un-index a document. + * + * A no-op outcome does nothing at all. That is what keeps a double-clicked + * publish button, a retried queue task and an empty edit from each producing a + * second event and a second index write. + * + * Cache invalidation is deliberately **not** here. It needs the Next runtime, + * which neither the API process nor the queue worker has; the Server Action + * owns it, and the scheduled path goes through the revalidation bridge. + */ +export const contentEditorialEffects = async ( + c: Context, + definition: AnyContentTypeDefinition, + outcome: ContentEditorialOutcome, + { pluginId, scheduledBy, scheduleId }: ContentEditorialEffectsOptions, +): Promise => { + if (!outcome.changed) return { event: null, search: null }; + + const event = await emitContentEvent( + c, + definition, + EVENT_ACTION[outcome.operation], + payloadFor(outcome, { scheduledBy, scheduleId }) as never, + // The plugin that owns the content type, not whichever module happens to be + // handling the request. Passed on every path, interactive and scheduled, so + // the envelope's owner is a property of the event rather than of how it was + // triggered. + { pluginId }, + ); + + return { + event, + search: await syncContentSearch(c, definition, { + changed: outcome.changed, + changedFields: outcome.changedFields, + operation: outcome.operation, + pluginId, + row: outcome.row, + }), + }; +}; diff --git a/packages/vitnode/src/content/server/editorial-service.test.ts b/packages/vitnode/src/content/server/editorial-service.test.ts new file mode 100644 index 000000000..78a66b761 --- /dev/null +++ b/packages/vitnode/src/content/server/editorial-service.test.ts @@ -0,0 +1,659 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import { + testCategoryContentType, + testEditorialNoteContentType, + testEditorialPostContentType, +} from "@/tests/content-fixtures"; + +import type { ContentRevisionSnapshot } from "../revisions"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentEditorialService } from "./editorial-service"; +import type { ContentModel } from "./model"; + +import { + ContentRevisionNotRestorable, + ContentVersionConflict, +} from "../errors"; +import { createContentModel } from "./model"; + +const categories = createContentModel(testCategoryContentType); +const posts = createContentModel(testEditorialPostContentType); +const notes = createContentModel(testEditorialNoteContentType); + +const STAFF = { type: "staff", userId: 7 } as const; +const SYSTEM = { type: "system", userId: null } as const; + +interface RecordedCall { + arg: unknown; + op: string; +} + +/** + * The chainable Drizzle stand-in from `service.test.ts`, plus `transaction`. + * + * The transaction callback receives the same handle, so a test can assert that + * the content write and the revision insert landed in the same unit of work by + * counting them - and `failAt` makes the revision insert throw so the rollback + * path is exercised rather than assumed. + */ +const createDbMock = ( + results: unknown[][], + { failAt }: { failAt?: number } = {}, +) => { + const calls: RecordedCall[] = []; + const queue = [...results]; + let started = 0; + let rolledBack = false; + + 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), + returning: (value: unknown) => record("returning", value), + set: (value: unknown) => record("set", value), + then: async (resolve: (rows: unknown[]) => TResult) => + Promise.resolve(rows).then(resolve), + values: (value: unknown) => record("values", value), + where: (value: unknown) => record("where", value), + }; + + return builder; + }; + + const start = (op: string) => (arg: unknown) => { + started += 1; + calls.push({ arg, op }); + + if (failAt !== undefined && started === failAt) { + throw new Error("insert failed"); + } + + return chain(queue.shift() ?? []); + }; + + const db = { + delete: start("delete"), + insert: start("insert"), + select: start("select"), + transaction: async ( + body: (tx: unknown) => Promise, + ): Promise => { + try { + return await body(db); + } catch (error) { + rolledBack = true; + throw error; + } + }, + update: start("update"), + }; + + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as Context; + + return { c, calls, didRollBack: () => rolledBack }; +}; + +const opsOf = (calls: RecordedCall[], op: string) => + calls.filter(call => call.op === op).map(call => call.arg); + +/** + * Which columns a Drizzle condition actually names. + * + * `JSON.stringify` cannot be used - a `PgColumn` holds a reference back to its + * table - so the nested `queryChunks` are walked instead, collecting anything + * that carries a column `name`. + */ +const columnsIn = (condition: unknown): string[] => { + const walk = (value: unknown): unknown[] => + value !== null && typeof value === "object" && "queryChunks" in value + ? (value.queryChunks as unknown[]).flatMap(walk) + : [value]; + + return walk(condition) + .map(chunk => (chunk as null | { name?: unknown })?.name) + .filter((name): name is string => typeof name === "string"); +}; + +/** + * `editorialService` is `undefined` for a content type without the workflow, so + * every call site would otherwise need a non-null assertion. Throwing here + * keeps the tests readable and fails loudly if a fixture ever loses its + * `editorial` block. + */ +const editorialServiceOf = ( + model: ContentModel, + c: Context, +): ContentEditorialService => { + const build = model.editorialService; + if (!build) + throw new Error(`${model.definition.id} has no editorial service`); + + return build(c, { pluginId: "@vitnode/test" }); +}; + +const service = (c: Context) => editorialServiceOf(posts, c); + +const noteService = (c: Context) => editorialServiceOf(notes, c); + +const row = (overrides: Record = {}) => ({ + createdAt: new Date("2024-01-01T00:00:00.000Z"), + excerpt: null, + id: 1, + publishedAt: null, + slug: "hello", + status: "draft", + title: "Hello", + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + version: 1, + views: 0, + ...overrides, +}); + +const snapshot = ( + fields: Record, + version = 1, +): ContentRevisionSnapshot => + ({ + contentTypeId: "test.editorial", + createdAt: "2024-01-01T00:00:00.000Z", + fields, + id: 1, + publication: { publishedAt: null, status: "draft" }, + schemaVersion: 1, + updatedAt: "2024-01-02T00:00:00.000Z", + version, + }) as ContentRevisionSnapshot; + +describe("editorial service", () => { + it("is undefined for a content type without the workflow", () => { + expect(categories.editorialService).toBeUndefined(); + expect(posts.editorialService).toBeDefined(); + }); + + describe("create", () => { + it("starts at version 1 and captures a create revision", async () => { + const { c, calls } = createDbMock([[row({ version: 1 })], [{ id: 10 }]]); + + const result = await service(c).create( + { title: "Hello" }, + { actor: STAFF }, + ); + + expect(result.changed).toBe(true); + expect(result.version).toBe(1); + expect(result.revisionId).toBe(10); + expect(result.operation).toBe("create"); + + const revision = opsOf(calls, "values")[1] as Record; + expect(revision.version).toBe(1); + expect(revision.operation).toBe("create"); + expect(revision.actorType).toBe("staff"); + expect(revision.actorUserId).toBe(7); + expect(revision.contentTypeId).toBe("test.editorial"); + expect(revision.pluginId).toBe("@vitnode/test"); + }); + + it("records a system actor without inventing a user id", async () => { + const { c, calls } = createDbMock([[row()], [{ id: 10 }]]); + + await service(c).create({ title: "Hello" }, { actor: SYSTEM }); + + const revision = opsOf(calls, "values")[1] as Record; + expect(revision.actorType).toBe("system"); + expect(revision.actorUserId).toBeNull(); + }); + }); + + describe("update", () => { + it("increments the version and captures one revision", async () => { + const { c, calls } = createDbMock([ + [row({ version: 4 })], + [row({ title: "Changed", version: 5 })], + [{ id: 11 }], + ]); + + const result = await service(c).update( + 1, + { title: "Changed" }, + { actor: STAFF, expectedVersion: 4 }, + ); + + expect(result?.changed).toBe(true); + expect(result?.version).toBe(5); + expect(result?.changedFields).toEqual(["title"]); + // Exactly one revision insert, and exactly one content update. + expect(opsOf(calls, "update")).toHaveLength(1); + expect(opsOf(calls, "insert")).toHaveLength(1); + }); + + it("guards the write on the expected version", async () => { + const { c, calls } = createDbMock([ + [row({ version: 4 })], + [row({ title: "Changed", version: 5 })], + [{ id: 11 }], + ]); + + await service(c).update( + 1, + { title: "Changed" }, + { actor: STAFF, expectedVersion: 4 }, + ); + + // `version = version + 1` travels with the same statement that checks it, + // which is what makes check-and-set atomic. + const set = opsOf(calls, "set")[0] as Record; + expect(set.version).toBeDefined(); + expect(set.title).toBe("Changed"); + }); + + it("reports a conflict when the version moved", async () => { + const { c } = createDbMock([ + [row({ version: 4 })], + // The guarded UPDATE matches nothing... + [], + // ...and the record is still there, at a newer version. + [{ version: 9 }], + ]); + + await expect( + service(c).update( + 1, + { title: "Changed" }, + { actor: STAFF, expectedVersion: 4 }, + ), + ).rejects.toThrow(ContentVersionConflict); + }); + + it("carries both versions on the conflict", async () => { + const { c } = createDbMock([[row({ version: 4 })], [], [{ version: 9 }]]); + + const error = await service(c) + .update(1, { title: "Changed" }, { actor: STAFF, expectedVersion: 4 }) + .catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(ContentVersionConflict); + const conflict = error as ContentVersionConflict; + expect(conflict.expectedVersion).toBe(4); + expect(conflict.currentVersion).toBe(9); + expect(conflict.itemId).toBe(1); + }); + + it("returns null for a record that does not exist", async () => { + const { c } = createDbMock([[]]); + + await expect( + service(c).update( + 99, + { title: "Changed" }, + { actor: STAFF, expectedVersion: 1 }, + ), + ).resolves.toBeNull(); + }); + + it("writes nothing at all when the diff is empty", async () => { + const { c, calls } = createDbMock([ + [row({ title: "Hello", version: 4 })], + ]); + + const result = await service(c).update( + 1, + { title: "Hello" }, + { actor: STAFF, expectedVersion: 4 }, + ); + + expect(result?.changed).toBe(false); + expect(result?.version).toBe(4); + expect(result?.revisionId).toBeNull(); + expect(opsOf(calls, "update")).toHaveLength(0); + expect(opsOf(calls, "insert")).toHaveLength(0); + }); + + it("rolls back the content write when the revision insert fails", async () => { + // 1 select, 2 update, 3 insert <- fails + const { c, didRollBack } = createDbMock( + [[row({ version: 4 })], [row({ title: "Changed", version: 5 })]], + { failAt: 3 }, + ); + + await expect( + service(c).update( + 1, + { title: "Changed" }, + { actor: STAFF, expectedVersion: 4 }, + ), + ).rejects.toThrow("insert failed"); + + expect(didRollBack()).toBe(true); + }); + }); + + describe("publish and unpublish", () => { + it("increments the version on a real transition", async () => { + const { c, calls } = createDbMock([ + [row({ publishedAt: new Date(), status: "published", version: 3 })], + [{ id: 12 }], + ]); + + const result = await service(c).publish(1, { actor: STAFF }); + + expect(result?.changed).toBe(true); + expect(result?.version).toBe(3); + expect( + (opsOf(calls, "values")[0] as Record).operation, + ).toBe("publish"); + }); + + it("leaves the version alone and writes no revision when idempotent", async () => { + const { c, calls } = createDbMock([ + // The guarded UPDATE matches nothing - already published. + [], + [row({ status: "published", version: 3 })], + ]); + + const result = await service(c).publish(1, { actor: STAFF }); + + expect(result?.changed).toBe(false); + expect(result?.version).toBe(3); + expect(result?.revisionId).toBeNull(); + expect(opsOf(calls, "insert")).toHaveLength(0); + }); + + it("returns null when the record is gone", async () => { + const { c } = createDbMock([[], []]); + + await expect(service(c).publish(1, { actor: STAFF })).resolves.toBeNull(); + }); + + it("enforces an expected version when one is supplied", async () => { + const { c } = createDbMock([[], [row({ status: "draft", version: 9 })]]); + + await expect( + service(c).publish(1, { actor: STAFF, expectedVersion: 4 }), + ).rejects.toThrow(ContentVersionConflict); + }); + + it("unpublishes without touching publishedAt", async () => { + const { c, calls } = createDbMock([ + [row({ publishedAt: new Date(), status: "draft", version: 4 })], + [{ id: 13 }], + ]); + + await service(c).unpublish(1, { actor: STAFF }); + + const set = opsOf(calls, "set")[0] as Record; + expect(set.status).toBe("draft"); + expect(set).not.toHaveProperty("publishedAt"); + }); + }); + + describe("delete", () => { + it("captures a final revision one version past the last", async () => { + const { c, calls } = createDbMock([[row({ version: 6 })], [{ id: 14 }]]); + + const result = await service(c).delete(1, { + actor: STAFF, + expectedVersion: 6, + }); + + expect(result?.operation).toBe("delete"); + // The row is gone, so nothing holds version 7 - but the history stays + // strictly increasing and the unique index stays meaningful. + expect(result?.version).toBe(7); + expect( + (opsOf(calls, "values")[0] as Record).version, + ).toBe(7); + }); + + it("guards the DELETE on the version it was given", async () => { + // The precondition has to be part of the statement that removes the row. + // Reading the version first and deleting second is the very race this + // exists to close. + const { c, calls } = createDbMock([[row({ version: 6 })], [{ id: 14 }]]); + + await service(c).delete(1, { actor: STAFF, expectedVersion: 6 }); + + expect(columnsIn(opsOf(calls, "where")[0])).toEqual( + expect.arrayContaining(["id", "version"]), + ); + }); + + it("returns null when there was nothing to delete", async () => { + // Nothing deleted and nothing there: the caller wanted it gone, and it + // is. A 404, never a conflict. + const { c } = createDbMock([[], []]); + + await expect( + service(c).delete(1, { actor: STAFF, expectedVersion: 6 }), + ).resolves.toBeNull(); + }); + + it("refuses to delete a version the caller has not seen", async () => { + // Nothing deleted, but the record is still there at a newer version - + // somebody saved after this table was rendered. + const { c } = createDbMock([[], [{ version: 9 }]]); + + await expect( + service(c).delete(1, { actor: STAFF, expectedVersion: 6 }), + ).rejects.toMatchObject({ + currentVersion: 9, + expectedVersion: 6, + name: "ContentVersionConflict", + }); + }); + + it("writes no revision when the delete is refused", async () => { + const { c, calls } = createDbMock([[], [{ version: 9 }]]); + + await expect( + service(c).delete(1, { actor: STAFF, expectedVersion: 6 }), + ).rejects.toThrow(); + + expect(opsOf(calls, "values")).toHaveLength(0); + }); + }); + + describe("retention", () => { + it("prunes past the window in the same transaction", async () => { + // Retention is 10 on this fixture, so a write at version 12 drops + // everything at or below version 2. + const { c, calls } = createDbMock([ + [row({ version: 11 })], + [row({ title: "Changed", version: 12 })], + [{ id: 15 }], + [], + ]); + + await service(c).update( + 1, + { title: "Changed" }, + { actor: STAFF, expectedVersion: 11 }, + ); + + expect(opsOf(calls, "delete")).toHaveLength(1); + }); + + it("does not prune while the history is inside the window", async () => { + const { c, calls } = createDbMock([ + [row({ version: 2 })], + [row({ title: "Changed", version: 3 })], + [{ id: 15 }], + ]); + + await service(c).update( + 1, + { title: "Changed" }, + { actor: STAFF, expectedVersion: 2 }, + ); + + expect(opsOf(calls, "delete")).toHaveLength(0); + }); + }); + + describe("restore", () => { + const restoreMock = ( + current: Record, + revisionSnapshot: ContentRevisionSnapshot, + rest: unknown[][] = [], + ) => + createDbMock([ + // findById + [{ id: 3, snapshot: revisionSnapshot, version: 2 }], + // readOne + [current], + ...rest, + ]); + + it("applies the snapshot's fields and creates a new version", async () => { + const { c, calls } = restoreMock( + row({ title: "Now", version: 8 }), + snapshot({ excerpt: null, slug: "hello", title: "Then", views: 0 }), + [[row({ title: "Then", version: 9 })], [{ id: 16 }]], + ); + + const result = await service(c).restore(1, 3, { + actor: STAFF, + expectedVersion: 8, + }); + + expect(result?.changed).toBe(true); + expect(result?.version).toBe(9); + expect(result?.changedFields).toEqual(["title"]); + + const revision = opsOf(calls, "values")[0] as Record; + expect(revision.operation).toBe("restore"); + expect(revision.restoredFromRevisionId).toBe(3); + // The restored revision's own version is never reinstated. + expect(revision.version).toBe(9); + }); + + it("never writes the publication columns", async () => { + const { c, calls } = restoreMock( + row({ status: "published", title: "Now", version: 8 }), + snapshot({ excerpt: null, slug: "hello", title: "Then", views: 0 }), + [[row({ title: "Then", version: 9 })], [{ id: 16 }]], + ); + + await service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 }); + + const set = opsOf(calls, "set")[0] as Record; + expect(set).not.toHaveProperty("status"); + expect(set).not.toHaveProperty("publishedAt"); + }); + + it("ignores a field the content type no longer declares", async () => { + const { c, calls } = restoreMock( + row({ title: "Now", version: 8 }), + snapshot({ + excerpt: null, + removedField: "gone", + slug: "hello", + title: "Then", + views: 0, + }), + [[row({ title: "Then", version: 9 })], [{ id: 16 }]], + ); + + await service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 }); + + const set = opsOf(calls, "set")[0] as Record; + expect(set).not.toHaveProperty("removedField"); + }); + + it("refuses a snapshot that is invalid under the current rules", async () => { + const { c } = restoreMock( + row({ title: "Now", version: 8 }), + // `title` has minLength 3 on this fixture. + snapshot({ excerpt: null, slug: "hello", title: "no", views: 0 }), + ); + + await expect( + service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 }), + ).rejects.toThrow(ContentRevisionNotRestorable); + }); + + it("names only field names when it refuses", async () => { + const { c } = restoreMock( + row({ title: "Now", version: 8 }), + snapshot({ excerpt: null, slug: "hello", title: "no", views: 0 }), + ); + + const error = await service(c) + .restore(1, 3, { actor: STAFF, expectedVersion: 8 }) + .catch((thrown: unknown) => thrown); + + expect((error as ContentRevisionNotRestorable).fields).toEqual(["title"]); + }); + + it("writes nothing when the snapshot matches the record", async () => { + const { c, calls } = restoreMock( + row({ title: "Same", version: 8 }), + snapshot({ excerpt: null, slug: "hello", title: "Same", views: 0 }), + ); + + const result = await service(c).restore(1, 3, { + actor: STAFF, + expectedVersion: 8, + }); + + expect(result?.changed).toBe(false); + expect(result?.revisionId).toBeNull(); + expect(opsOf(calls, "update")).toHaveLength(0); + }); + + it("reports a conflict when the version moved", async () => { + const { c } = restoreMock( + row({ title: "Now", version: 8 }), + snapshot({ excerpt: null, slug: "hello", title: "Then", views: 0 }), + [[], [{ version: 12 }]], + ); + + await expect( + service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 }), + ).rejects.toThrow(ContentVersionConflict); + }); + + it("returns null for a revision that is not this record's", async () => { + const { c } = createDbMock([[]]); + + await expect( + service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 }), + ).resolves.toBeNull(); + }); + }); + + describe("without publication", () => { + it("still versions and captures revisions", async () => { + const { c, calls } = createDbMock([ + [{ body: null, id: 1, title: "Note", version: 1 }], + [{ id: 20 }], + ]); + + const result = await noteService(c).create( + { title: "Note" }, + { actor: STAFF }, + ); + + expect(result.version).toBe(1); + const revision = opsOf(calls, "values")[1] as Record; + // No publication block on the snapshot - there is no lifecycle to record. + expect( + (revision.snapshot as ContentRevisionSnapshot).publication, + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts new file mode 100644 index 000000000..d802826df --- /dev/null +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -0,0 +1,637 @@ +import type { SQL } from "drizzle-orm"; +import type { + PgColumn, + PgTableWithColumns, + TableConfig, +} from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq, ne, sql } from "drizzle-orm"; + +import type { ContentActor, ContentRevisionOperation } from "../revisions"; +import type { ContentSchemas } from "../schemas"; +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentFieldName, + ContentSelect, + ContentUpdateInput, +} from "../types"; +import type { ContentRevisionsModel } from "./revisions-model"; +import type { ContentSchedulesModel } from "./schedules-model"; +import type { ContentDatabase } from "./service"; + +import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const"; +import { + ContentEngineError, + ContentRevisionNotRestorable, + ContentVersionConflict, +} from "../errors"; +import { diffChangedFields, toColumnValues } from "./query"; +import { + contentRevisionSnapshot, + projectRevisionSnapshot, +} from "./revision-snapshot"; +import { createContentRevisionsModel } from "./revisions-model"; +import { createContentSchedulesModel } from "./schedules-model"; +import { createSlugNormalizer } from "./slugs"; + +/** + * Everything the post-commit effects need, and nothing they have to re-read. + * + * `previousSlug` is the one field that cannot be recovered after the fact: once + * the write returns, the old URL is gone, and invalidating the wrong cache tag + * leaves a moved page resolving at its old address. + */ +export interface ContentEditorialOutcome { + /** `false` when nothing moved: no write, no revision, no event, no tags. */ + changed: boolean; + changedFields: ContentFieldName[]; + operation: ContentRevisionOperation; + /** The slug the record answered to *before* this mutation, if it has one. */ + previousSlug: null | string; + /** Set only by `restore`: the revision the values came from. */ + restoredFromRevisionId: null | number; + /** `null` on a no-op, since no revision was written. */ + revisionId: null | number; + row: ContentSelect; + version: number; +} + +export interface ContentEditorialOptions { + actor: ContentActor; + /** Join an existing transaction instead of opening one. */ + tx?: ContentDatabase; +} + +export interface ContentEditorialWriteOptions extends ContentEditorialOptions { + expectedVersion: number; +} + +export interface ContentEditorialPublicationOptions extends ContentEditorialOptions { + /** Enforced when supplied. Publishing overwrites no field values, so it is + * optional: requiring it would fail the publish button whenever a colleague + * had fixed a typo, for no protection against a lost update. */ + expectedVersion?: number; +} + +export interface ContentEditorialService { + create: ( + values: ContentCreateInput, + options: ContentEditorialOptions, + ) => Promise>; + /** + * Removes a record, and refuses if it moved since the caller read it. + * + * `expectedVersion` is required for the same reason `update` requires it: a + * delete is the widest possible overwrite. Somebody looking at v4 in a stale + * table must not be able to remove the v5 a colleague just wrote, and "are + * you sure?" cannot ask about a change the person has not seen. + */ + delete: ( + id: number, + options: ContentEditorialWriteOptions, + ) => Promise | null>; + publish: ( + id: number, + options: ContentEditorialPublicationOptions, + ) => Promise | null>; + restore: ( + id: number, + revisionId: number, + options: ContentEditorialWriteOptions, + ) => Promise | null>; + /** Revision reads. Writes go through the mutations above. */ + revisions: ContentRevisionsModel; + /** + * Scheduled transitions, or `undefined` without `editorial.scheduling`. + * + * `undefined` rather than a throwing stub, matching `publicService` and + * `editorialService` themselves - the check reads naturally in code that does + * not know which content type it was handed. + */ + schedules: ContentSchedulesModel | undefined; + unpublish: ( + id: number, + options: ContentEditorialPublicationOptions, + ) => Promise | null>; + update: ( + id: number, + values: ContentUpdateInput, + options: ContentEditorialWriteOptions, + ) => Promise | null>; +} + +/** + * The transactional half of the Content Engine. + * + * Everything here holds one rule: **the content write, the version increment + * and the revision insert are one transaction, and nothing else is in it.** No + * event, no search call, no cache API, no HTTP - those all run after the commit, + * because a rolled-back transaction cannot un-send them. + * + * A caller that already owns a transaction passes `tx` and this joins it. A + * caller that does not gets one opened here, which is what makes + * `service.update(...)` atomic by default rather than only when someone + * remembered. + */ +export const createContentEditorialService = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + columns, + definition, + pluginId, + schemas, + table, +}: { + c: Context; + columns: Record; + definition: TDefinition; + pluginId: string; + schemas: ContentSchemas; + table: PgTableWithColumns; +}): ContentEditorialService => { + if (!definition.editorial.enabled) { + throw new ContentEngineError( + "The editorial service needs `editorial: { enabled: true }` on the content type.", + { contentTypeId: definition.id }, + ); + } + + const contentTypeId = definition.id; + const fields = definition.fields; + const fieldNames = Object.keys(fields) as ContentFieldName[]; + const primaryCursor = columns.id; + const versionColumn = columns.version; + const publication = definition.publication.enabled; + const slugField = definition.publicApi.enabled + ? definition.publicApi.slugField + : null; + + const ownColumnNames = [ + "id", + "createdAt", + "updatedAt", + ...(publication ? CONTENT_PUBLICATION_FIELDS : []), + ...CONTENT_EDITORIAL_FIELDS, + ...fieldNames, + ]; + const ownSelection = (): Record => + Object.fromEntries(ownColumnNames.map(name => [name, columns[name]])); + + const revisions = createContentRevisionsModel({ c, definition, pluginId }); + const schedules = definition.editorial.scheduling.enabled + ? createContentSchedulesModel({ c, definition, pluginId }) + : undefined; + const { withCreateSlugs, withUpdateSlugs } = createSlugNormalizer( + contentTypeId, + fields, + ); + + const toRow = (row: Record): ContentSelect => + row as ContentSelect; + + const versionOf = (row: Record): number => + typeof row.version === "number" ? row.version : 1; + + const slugOf = (row: null | Record): null | string => { + if (!row || slugField === null) return null; + const value = row[slugField]; + + return typeof value === "string" ? value : null; + }; + + const readOne = async ( + id: number, + database: ContentDatabase, + ): Promise> => { + const [row] = await database + .select(ownSelection()) + .from(table) + .where(eq(primaryCursor, id)) + .limit(1); + + return row ?? null; + }; + + /** Runs `body` in the caller's transaction, or in one opened for it. */ + const transact = async ( + options: ContentEditorialOptions, + body: (tx: ContentDatabase) => Promise, + ): Promise => { + if (options.tx) return await body(options.tx); + + return await c.get("db").transaction(async tx => await body(tx)); + }; + + const capture = async ( + tx: ContentDatabase, + { + actor, + changedFields, + operation, + restoredFromRevisionId, + row, + version, + }: { + actor: ContentActor; + changedFields: readonly string[]; + operation: ContentRevisionOperation; + restoredFromRevisionId?: number; + row: Record; + version: number; + }, + ): Promise => + await revisions.capture(tx, { + actor, + changedFields, + itemId: typeof row.id === "number" ? row.id : 0, + operation, + restoredFromRevisionId, + // Stamped with the version the record now holds, which for a delete is the + // one it would have had - see `remove` below. + snapshot: contentRevisionSnapshot(definition, { ...row, version }), + version, + }); + + /** + * The conditional write every editorial mutation goes through. + * + * `WHERE id = $id AND version = $expected` is the whole locking mechanism: + * two editors racing produce one `UPDATE` that matches and one that does not, + * with no read-then-write window in between. The follow-up `SELECT` runs only + * when nothing matched, to tell a deleted record (404) from a moved one (409) + * - the same shape `transition` in the plain service already uses. + */ + const guardedWrite = async ( + tx: ContentDatabase, + id: number, + expectedVersion: number, + values: Record, + ): Promise> => { + const [row] = await tx + .update(table) + .set({ ...values, version: sql`${versionColumn} + 1` }) + .where(and(eq(primaryCursor, id), eq(versionColumn, expectedVersion))) + .returning(ownSelection()); + + if (row) return row; + + const [current] = await tx + .select({ version: versionColumn }) + .from(table) + .where(eq(primaryCursor, id)) + .limit(1); + + if (!current) return null; + + throw new ContentVersionConflict({ + contentTypeId, + currentVersion: versionOf(current), + expectedVersion, + itemId: id, + }); + }; + + /** + * Publish and unpublish, which guard on the *state* rather than the version. + * + * The state guard is what makes them idempotent, and idempotency is what makes + * a retried queue task harmless. An `expectedVersion`, when supplied, is + * `AND`ed on top rather than replacing it. + */ + const transition = async ( + id: number, + options: ContentEditorialPublicationOptions, + operation: "publish" | "unpublish", + values: Record, + guard: SQL, + ): Promise | null> => + await transact(options, async tx => { + const conditions = [eq(primaryCursor, id), guard]; + if (options.expectedVersion !== undefined) { + conditions.push(eq(versionColumn, options.expectedVersion)); + } + + const [row] = await tx + .update(table) + .set({ ...values, version: sql`${versionColumn} + 1` }) + .where(and(...conditions)) + .returning(ownSelection()); + + if (!row) { + const current = await readOne(id, tx); + if (!current) return null; + + // Nothing matched but the record exists: either it was already in the + // requested state, or the version moved. Only the second is an error. + if ( + options.expectedVersion !== undefined && + versionOf(current) !== options.expectedVersion + ) { + throw new ContentVersionConflict({ + contentTypeId, + currentVersion: versionOf(current), + expectedVersion: options.expectedVersion, + itemId: id, + }); + } + + return { + changed: false, + changedFields: [], + operation, + previousSlug: slugOf(current), + restoredFromRevisionId: null, + revisionId: null, + row: toRow(current), + version: versionOf(current), + }; + } + + const version = versionOf(row); + const revisionId = await capture(tx, { + actor: options.actor, + changedFields: [], + operation, + row, + version, + }); + + return { + changed: true, + changedFields: [], + operation, + previousSlug: slugOf(row), + restoredFromRevisionId: null, + revisionId, + row: toRow(row), + version, + }; + }); + + return { + create: async (values, options) => + await transact(options, async tx => { + const parsed = schemas.create.parse(values) as Record; + + const [row] = await tx + .insert(table) + .values(toColumnValues(fields, withCreateSlugs(parsed))) + .returning(ownSelection()); + + const version = versionOf(row); + const revisionId = await capture(tx, { + actor: options.actor, + // Everything is new, so every field "changed" - which is what the + // history should say about a create. + changedFields: fieldNames, + operation: "create", + row, + version, + }); + + return { + changed: true, + changedFields: fieldNames, + operation: "create", + previousSlug: null, + restoredFromRevisionId: null, + revisionId, + row: toRow(row), + version, + }; + }), + + delete: async (id, options) => + await transact(options, async tx => { + // Same guard as `guardedWrite`, in a `DELETE` - the version has to be + // part of the statement that removes the row, not checked before it. + const [row] = await tx + .delete(table) + .where( + and( + eq(primaryCursor, id), + eq(versionColumn, options.expectedVersion), + ), + ) + .returning(ownSelection()); + + if (!row) { + const [current] = await tx + .select({ version: versionColumn }) + .from(table) + .where(eq(primaryCursor, id)) + .limit(1); + + // Gone already is a 404 and not a conflict: the caller wanted the + // record removed, and it is. + if (!current) return null; + + throw new ContentVersionConflict({ + contentTypeId, + currentVersion: versionOf(current), + expectedVersion: options.expectedVersion, + itemId: id, + }); + } + + // The row is gone, so no version survives to hold this one. Recording + // `version + 1` keeps the per-record history strictly increasing and + // keeps the unique index meaningful - the alternative collides with the + // revision that last wrote this version. + const version = versionOf(row) + 1; + const revisionId = await capture(tx, { + actor: options.actor, + changedFields: [], + operation: "delete", + row, + version, + }); + + return { + changed: true, + changedFields: [], + operation: "delete", + previousSlug: slugOf(row), + restoredFromRevisionId: null, + revisionId, + row: toRow(row), + version, + }; + }), + + publish: async (id, options) => + await transition( + id, + options, + "publish", + { + // 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"), + ), + + restore: async (id, revisionId, options) => + await transact(options, async tx => { + const revision = await revisions.findById(id, revisionId, tx); + if (!revision) return null; + + const current = await readOne(id, tx); + if (!current) return null; + + // Currently declared fields only. A field the content type has since + // dropped is ignored; one added since is absent, so the record keeps + // what it has. + const projected = projectRevisionSnapshot( + definition, + revision.snapshot, + ); + + const parsed = schemas.update.safeParse(projected); + if (!parsed.success) { + throw new ContentRevisionNotRestorable({ + contentTypeId, + // Field names only - never the issue tree, which names internal + // paths and is already described by the route's OpenAPI schema. + fields: [ + ...new Set( + parsed.error.issues + .map(issue => String(issue.path[0] ?? "")) + .filter(name => name !== ""), + ), + ], + revisionId, + }); + } + + const patch = withUpdateSlugs(parsed.data); + const changedFields = diffChangedFields(fieldNames, current, patch); + + if (changedFields.length === 0) { + return { + changed: false, + changedFields, + operation: "restore" as const, + previousSlug: slugOf(current), + // Nothing was restored, so nothing was restored *from*. + restoredFromRevisionId: null, + revisionId: null, + row: toRow(current), + version: versionOf(current), + }; + } + + const row = await guardedWrite( + tx, + id, + options.expectedVersion, + toColumnValues( + fields, + Object.fromEntries(changedFields.map(key => [key, patch[key]])), + ), + ); + if (!row) return null; + + const version = versionOf(row); + const newRevisionId = await capture(tx, { + actor: options.actor, + changedFields, + operation: "restore", + restoredFromRevisionId: revisionId, + row, + version, + }); + + return { + changed: true, + changedFields, + operation: "restore" as const, + previousSlug: slugOf(current), + restoredFromRevisionId: revisionId, + revisionId: newRevisionId, + row: toRow(row), + version, + }; + }), + + revisions, + + schedules, + + unpublish: async (id, options) => + await transition( + id, + options, + "unpublish", + { status: "draft" }, + eq(columns.status, "published"), + ), + + update: async (id, values, options) => + await transact(options, async tx => { + // Parsed before the row is read, so an invalid payload never costs a + // query. Slugs are normalised before the diff, so re-sending the stored + // slug in a different case counts as no change. + const patch = withUpdateSlugs(schemas.update.parse(values)); + + const current = await readOne(id, tx); + if (!current) return null; + + const changedFields = diffChangedFields(fieldNames, current, patch); + + // A no-op is still a *successful* write from the caller's point of view, + // but it must not bump the version or leave a revision: an editor who + // pressed save twice has not created two versions of anything. The stale + // `expectedVersion` is deliberately not checked here - there is nothing + // to overwrite, so there is nothing to conflict about. + if (changedFields.length === 0) { + return { + changed: false, + changedFields, + operation: "update" as const, + previousSlug: slugOf(current), + restoredFromRevisionId: null, + revisionId: null, + row: toRow(current), + version: versionOf(current), + }; + } + + const row = await guardedWrite( + tx, + id, + options.expectedVersion, + toColumnValues( + fields, + Object.fromEntries(changedFields.map(key => [key, patch[key]])), + ), + ); + if (!row) return null; + + const version = versionOf(row); + const revisionId = await capture(tx, { + actor: options.actor, + changedFields, + operation: "update", + row, + version, + }); + + return { + changed: true, + changedFields, + operation: "update" as const, + previousSlug: slugOf(current), + restoredFromRevisionId: null, + revisionId, + row: toRow(row), + version, + }; + }), + }; +}; diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts index c325f3b2b..1d4b10ff6 100644 --- a/packages/vitnode/src/content/server/emit.ts +++ b/packages/vitnode/src/content/server/emit.ts @@ -1,6 +1,10 @@ import type { Context } from "hono"; -import type { VitNodeEventName } from "../../api/models/events"; +import type { + EventEmitOptions, + EventEmitResult, + VitNodeEventName, +} from "../../api/models/events"; import type { ContentCreatedPayload, ContentDeletedPayload, @@ -31,7 +35,11 @@ type ContentPayload = * autofixer and the build take turns breaking each other. This does not move. */ interface ContentEventEmitter { - emit: (name: VitNodeEventName, payload: ContentPayload) => Promise; + emit: ( + name: VitNodeEventName, + payload: ContentPayload, + options?: EventEmitOptions, + ) => Promise; } /** @@ -47,15 +55,30 @@ interface ContentEventEmitter { * * Call it only once the database write has returned - never inside a * transaction callback. + * + * The result is **returned, not swallowed**. `EventsModel.emit` never throws, so + * a listener that fell over is reported rather than raised - and a caller that + * only awaits this call has silently accepted whatever happened. Interactive + * routes are right to: the mutation committed and the person is owed a 200 + * either way. The scheduled-effects task is not, and it reads `failures`. */ export const emitContentEvent = async ( c: Context, definition: AnyContentTypeDefinition, action: ContentEventAction, payload: ContentPayload, -): Promise => { + options?: { + /** + * The plugin that owns the content type - which is not always the plugin + * handling the request. A scheduled transition runs inside core's queue + * handler, and `content.example.article.published` belongs to the example + * plugin however it was triggered. + */ + pluginId?: string; + }, +): Promise => { const name = contentEventName(definition.id, action) as VitNodeEventName; const events = c.get("events") as unknown as ContentEventEmitter; - await events.emit(name, payload); + return await events.emit(name, payload, { pluginId: options?.pluginId }); }; diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index 2f00c4229..361bd9a22 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -1,7 +1,16 @@ import { HTTPException } from "hono/http-exception"; import { ZodError } from "zod"; -import { ContentInputError } from "../errors"; +import type { ContentConflict, ContentUnprocessable } from "../conflicts"; +import type { ContentScheduleCode } from "../schedules"; + +import { CONTENT_CONFLICT_CODES, CONTENT_UNPROCESSABLE_CODES } from "../const"; +import { + ContentInputError, + ContentRevisionNotRestorable, + ContentScheduleError, + ContentVersionConflict, +} from "../errors"; /** Postgres error codes the engine translates into a useful HTTP status. */ const FOREIGN_KEY_VIOLATION = "23503"; @@ -26,17 +35,91 @@ const errorCode = (error: unknown, depth = 0): string | undefined => { return errorCode(cause, depth + 1); }; +/** + * A JSON error body, carried on the exception itself. + * + * `HTTPException` normally renders its `message` as text, but it also accepts a + * ready-made `Response` - and `app.onError` returns `error.getResponse()` + * verbatim, so the body survives untouched. That is what lets an editorial + * route answer a machine-readable 409 without a second error channel. + */ +const jsonError = (status: 400 | 409 | 422, body: unknown): HTTPException => + new HTTPException(status, { + res: Response.json(body, { status }), + }); + +/** A structured 409. Editorial content types only - see `zodContentConflict`. */ +export const contentConflict = (body: ContentConflict): HTTPException => + jsonError(409, body); + +/** A structured 422, for a revision that no longer fits the content type. */ +export const contentUnprocessable = ( + body: ContentUnprocessable, +): HTTPException => jsonError(422, body); + +/** + * A structured 400, for a schedule the rules refuse. + * + * 400 rather than 409: nothing is in conflict, the request simply asked for a + * time that cannot work. The `code` is what lets the dialog point at the date + * field instead of raising a general error. + */ +export const contentScheduleRejected = (body: { + code: ContentScheduleCode; + contentTypeId: string; +}): HTTPException => jsonError(400, body); + /** * Turns a Postgres constraint failure into an HTTP response. * * The driver's message can name columns, constraints and even values, so it * never reaches the client - only a generic sentence does. Anything unrecognised * is rethrown for `app.onError`, which logs the detail and returns a bare 500. + * + * `structured` opts an editorial content type into JSON bodies for the two + * statuses a client has to branch on. It is off by default, so every Stage 1-3 + * route answers exactly as it did before. */ export const rethrowAsHttpError = ( error: unknown, - { action }: { action: "create" | "delete" | "update" }, + { + action, + contentTypeId, + itemId, + structured = false, + }: { + action: "create" | "delete" | "update"; + contentTypeId?: string; + itemId?: number; + structured?: boolean; + }, ): never => { + if (error instanceof ContentVersionConflict) { + throw contentConflict({ + code: CONTENT_CONFLICT_CODES.version, + contentTypeId: error.contentTypeId ?? contentTypeId ?? "", + currentVersion: error.currentVersion, + expectedVersion: error.expectedVersion, + itemId: error.itemId, + }); + } + + if (error instanceof ContentScheduleError) { + throw contentScheduleRejected({ + code: error.code, + contentTypeId: error.contentTypeId ?? contentTypeId ?? "", + }); + } + + if (error instanceof ContentRevisionNotRestorable) { + throw contentUnprocessable({ + code: CONTENT_UNPROCESSABLE_CODES.notRestorable, + contentTypeId: error.contentTypeId ?? contentTypeId ?? "", + fields: error.fields, + revisionId: error.revisionId, + }); + } + // The service validates its own input, so a payload that slipped past the // route's validator surfaces here. The issue tree stays out of the response: // it names internal field paths, and the route schema already described the @@ -66,24 +149,40 @@ export const rethrowAsHttpError = ( case NOT_NULL_VIOLATION: throw new HTTPException(400, { message: "A required field is missing." }); case UNIQUE_VIOLATION: - throw new HTTPException(409, { - message: "A record with these values already exists.", - }); + // Same status either way; an editorial route just says it in a shape a + // client can branch on, alongside the version conflict it shares with. + throw structured + ? contentConflict({ + code: CONTENT_CONFLICT_CODES.unique, + contentTypeId: contentTypeId ?? "", + itemId: itemId ?? null, + }) + : new HTTPException(409, { + message: "A record with these values already exists.", + }); default: throw error; } }; +export interface ContentHttpErrorOptions { + contentTypeId?: string; + itemId?: number; + /** Answer 409 and 422 with a JSON body. Editorial content types only. */ + structured?: boolean; +} + /** Runs a write and maps any constraint failure onto an HTTP status. */ export const withHttpErrors = async ( action: "create" | "delete" | "update", run: () => Promise, + options: ContentHttpErrorOptions = {}, ): Promise => { try { return await run(); } catch (error) { if (error instanceof HTTPException) throw error; - return rethrowAsHttpError(error, { action }); + return rethrowAsHttpError(error, { action, ...options }); } }; diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 5af1d8153..c0b864870 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -6,20 +6,58 @@ * throws under plain Node, and both `apps/api` and `drizzle-kit` load these * modules in plain Node. */ +export { CONTENT_SYSTEM_ACTOR, resolveContentActor } from "./actor"; export { buildContentColumn, + buildEditorialColumns, buildPublicationColumns, buildSystemColumns, } from "./column-builders"; export type { ColumnReferenceThunk } from "./column-builders"; +export { contentEditorialEffects } from "./editorial-effects"; +export type { + ContentEditorialEffectsOptions, + ContentEditorialEffectsResult, +} from "./editorial-effects"; +export { createContentEditorialService } from "./editorial-service"; +export type { + ContentEditorialOptions, + ContentEditorialOutcome, + ContentEditorialPublicationOptions, + ContentEditorialService, + ContentEditorialWriteOptions, +} from "./editorial-service"; export { emitContentEvent } from "./emit"; -export { rethrowAsHttpError, withHttpErrors } from "./http-errors"; -export { createContentModel } from "./model"; -export type { ContentModel } from "./model"; +export { + contentConflict, + contentUnprocessable, + rethrowAsHttpError, + withHttpErrors, +} from "./http-errors"; +export type { ContentHttpErrorOptions } from "./http-errors"; +export { createContentModel, findContentModel } from "./model"; +export type { + AnyContentModel, + ContentModel, + RegisteredContentModel, +} from "./model"; export { buildContentAdminModule } from "./module"; +export { + createContentPreviewToken, + verifyContentPreviewToken, + zodContentPreviewTokenPayload, +} from "./preview-token"; +export type { + ContentPreviewToken, + ContentPreviewTokenPayload, +} from "./preview-token"; export { buildContentPublicModule } from "./public-module"; export { buildContentPublicRoutes } from "./public-routes"; -export { createContentPublicService } from "./public-service"; +export { + contentPublicSelection, + createContentPublicProjector, + createContentPublicService, +} from "./public-service"; export type { ContentPublicFindManyArgs, ContentPublicService, @@ -40,7 +78,41 @@ export { } from "./query"; export { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; export type { ReferenceTarget } from "./references"; +export { + contentRevisionSnapshot, + contentSnapshotRow, + projectRevisionSnapshot, +} from "./revision-snapshot"; +export { + CONTENT_REVISIONS_DEFAULT_PAGE_SIZE, + CONTENT_REVISIONS_MAX_PAGE_SIZE, + createContentRevisionsModel, +} from "./revisions-model"; +export type { + ContentRevisionCaptureInput, + ContentRevisionPage, + ContentRevisionsModel, +} from "./revisions-model"; export { buildContentRoutes } from "./routes"; +export { + contentScheduleEffectsPayloadSchema, + runContentScheduleEffects, +} from "./schedule-effects"; +export type { + ContentScheduleEffectsOutcome, + ContentScheduleEffectsPayload, +} from "./schedule-effects"; +export { + claimContentSchedule, + createContentSchedulesModel, + pruneContentSchedules, + recordContentScheduleEffectsError, + settleContentSchedule, +} from "./schedules-model"; +export type { + ClaimedContentSchedule, + ContentSchedulesModel, +} from "./schedules-model"; export { contentSearchDocument } from "./search-document"; export { createContentSearchIndexer } from "./search-indexer"; export type { ContentSearchIndexer } from "./search-indexer"; @@ -64,11 +136,14 @@ export type { ContentServiceOptions, ContentUpdateResult, } from "./service"; +export { createSlugNormalizer } from "./slugs"; +export type { ContentSlugNormalizer } from "./slugs"; export { contentTableColumns, createContentTable } from "./table"; export type { ContentColumnBuilder, ContentColumnBuilders, ContentColumnName, + ContentEditorialColumnBuilders, ContentPublicationColumnBuilders, ContentReferences, ContentSystemColumnBuilders, diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index ebea0978d..04d8efc3a 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 { ContentEditorialService } from "./editorial-service"; import type { ContentPublicService } from "./public-service"; import type { ContentService } from "./service"; import type { @@ -11,6 +12,7 @@ import type { ContentTableFor, } from "./types"; +import { createContentEditorialService } from "./editorial-service"; import { createContentPublicService } from "./public-service"; import { createContentService } from "./service"; import { contentTableColumns, createContentTable } from "./table"; @@ -19,6 +21,20 @@ export interface ContentModel { /** Column name -> Drizzle column, for filters, ordering and custom queries. */ columns: Record, PgColumn>; definition: TDefinition; + /** + * The transactional editorial repository, or `undefined` when the content + * type has no `editorial` block. + * + * `undefined` rather than a throwing stub, for the same reason + * `publicService` is: the check reads naturally in a route builder that has + * no idea which content type it was handed. + */ + editorialService: + | (( + c: Context, + options: { pluginId: string }, + ) => ContentEditorialService) + | undefined; /** * The read-only public repository, or `undefined` when the content type has * no `publicApi`. @@ -36,6 +52,35 @@ export interface ContentModel { table: ContentTableFor; } +/** + * Any content model, for code that holds a collection of them. + * + * The same shape `AnyContentTypeDefinition` provides for definitions, and it + * exists for the same reason: background work - the scheduled-publication task, + * the cleanup cron - looks a model up by content type id and cannot know which + * concrete one it will get. + */ +export type AnyContentModel = ContentModel; + +/** + * A model plus the plugin that registered it. + * + * The owner is not on the model itself because `createContentModel` is called + * from `src/database/.ts`, which has no reason to know it. It is + * attached here, at collection time, where `buildApiPlugin` already knows. + */ +export interface RegisteredContentModel { + model: AnyContentModel; + pluginId: string; +} + +/** Finds the model for one content type id, or `undefined`. */ +export const findContentModel = ( + models: readonly RegisteredContentModel[], + contentTypeId: string, +): RegisteredContentModel | undefined => + models.find(entry => entry.model.definition.id === contentTypeId); + /** * Turns a content type definition into its database model. * @@ -72,6 +117,22 @@ export const createContentModel = < return { columns, definition, + // The plugin id arrives at call time rather than being captured here: a + // revision is stamped with its owner, and `createContentModel` is called + // from `src/database/*.ts`, which does not otherwise need to know it. Every + // caller - the generated routes, the queue handler - already carries it, + // the same way `createContentSearchIndexer` receives it. + editorialService: definition.editorial.enabled + ? (c: Context, { pluginId }: { pluginId: string }) => + createContentEditorialService({ + c, + columns, + definition, + pluginId, + schemas, + table, + }) + : undefined, publicService: definition.publicApi.enabled ? (c: Context) => createContentPublicService({ c, columns, definition, table }) diff --git a/packages/vitnode/src/content/server/module.ts b/packages/vitnode/src/content/server/module.ts index af9eb837f..08265a407 100644 --- a/packages/vitnode/src/content/server/module.ts +++ b/packages/vitnode/src/content/server/module.ts @@ -55,6 +55,10 @@ export const buildContentAdminModule =

({ routes: [], modules, contentTypes: contentTypes.map(model => model.definition), + // The models themselves, not just the definitions. Background work - the + // scheduled-publication task - needs the table and the editorial service, + // and it runs in a cron request that knows nothing but a content type id. + contentModels: contentTypes, // A content type without `search` contributes nothing, so the two module // builders can keep taking the same array. The plugin id travels with the // indexer so a rebuild - which runs in the core cron request - still stores diff --git a/packages/vitnode/src/content/server/preview-config.test.ts b/packages/vitnode/src/content/server/preview-config.test.ts new file mode 100644 index 000000000..ea7e8696b --- /dev/null +++ b/packages/vitnode/src/content/server/preview-config.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + testEditorialNoteContentType, + testEditorialPostContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET } from "../../lib/config"; +import { + assertContentPreviewConfig, + contentPreviewConfigProblems, + contentPreviewSecretProblem, +} from "./preview-config"; + +const STRONG = "unit-test-content-preview-secret-0123456789"; + +/** `testEditorialPostContentType` is the only fixture with preview enabled. */ +const previewable = [ + { definition: testEditorialPostContentType, pluginId: "@vitnode/example" }, +]; +const withoutPreview = [ + { definition: testPostContentType, pluginId: "@vitnode/example" }, + { definition: testEditorialNoteContentType, pluginId: "@vitnode/example" }, +]; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("contentPreviewSecretProblem", () => { + it("accepts 32 random-looking bytes", () => { + expect(contentPreviewSecretProblem(STRONG)).toBeNull(); + }); + + it("rejects a missing secret", () => { + expect(contentPreviewSecretProblem(undefined)).toMatch(/not set/); + expect(contentPreviewSecretProblem("")).toMatch(/not set/); + }); + + it("rejects the fallback that ships in the source", () => { + // The whole reason this check exists: the value is public, so a token + // signed with it is a token anyone can sign. + expect( + contentPreviewSecretProblem(INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET), + ).toMatch(/placeholder/); + }); + + it("rejects a secret short enough to attack", () => { + expect(contentPreviewSecretProblem("hunter2")).toMatch(/shorter than 32/); + // 31 bytes: one short, and still refused. + expect(contentPreviewSecretProblem("a".repeat(31))).toMatch( + /shorter than 32/, + ); + expect(contentPreviewSecretProblem("a".repeat(32))).toBeNull(); + }); + + it("counts bytes rather than characters", () => { + // 16 emoji is 16 characters and 64 bytes. Counting characters would have + // rejected it; counting bytes is what the key length actually is. + expect(contentPreviewSecretProblem("🔐".repeat(16))).toBeNull(); + expect(contentPreviewSecretProblem("🔐".repeat(4))).toMatch(/shorter/); + }); +}); + +describe("contentPreviewConfigProblems", () => { + it("is empty for a good secret and parseable origins", () => { + expect(contentPreviewConfigProblems(STRONG)).toEqual([]); + }); + + it("reports an unparseable web origin", () => { + // A preview link resolved against this would not be a link. + vi.stubEnv("NEXT_PUBLIC_WEB_URL", "not a url"); + + expect(contentPreviewConfigProblems(STRONG)).toEqual([ + expect.stringContaining("NEXT_PUBLIC_WEB_URL"), + ]); + + vi.unstubAllEnvs(); + }); + + it("reports an unparseable API origin", () => { + vi.stubEnv("NEXT_PUBLIC_API_URL", ""); + + expect(contentPreviewConfigProblems(STRONG)).toEqual([ + expect.stringContaining("NEXT_PUBLIC_API_URL"), + ]); + + vi.unstubAllEnvs(); + }); +}); + +describe("assertContentPreviewConfig", () => { + it("says nothing when no content type can be previewed", () => { + // Nothing signs anything, so there is nothing to secure. + expect(() => + assertContentPreviewConfig({ + contentTypes: withoutPreview, + isProduction: true, + secret: undefined, + }), + ).not.toThrow(); + }); + + it("boots happily with a real secret", () => { + expect(() => + assertContentPreviewConfig({ + contentTypes: previewable, + isProduction: true, + secret: STRONG, + }), + ).not.toThrow(); + }); + + it.each([ + ["missing", undefined], + ["the published fallback", INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET], + ["too short", "hunter2"], + ])("refuses to boot production when the secret is %s", (_, secret) => { + expect(() => + assertContentPreviewConfig({ + contentTypes: previewable, + isProduction: true, + secret, + }), + ).toThrow(/CONTENT_PREVIEW_SECRET/); + }); + + it("names the content types that made it mandatory", () => { + expect(() => + assertContentPreviewConfig({ + contentTypes: previewable, + isProduction: true, + secret: undefined, + }), + ).toThrow(/test\.editorial/); + }); + + it("tells the reader how to generate one", () => { + expect(() => + assertContentPreviewConfig({ + contentTypes: previewable, + isProduction: true, + secret: undefined, + }), + ).toThrow(/openssl rand/); + }); + + it("lets `next build` collect page data without the secret", () => { + // Next imports every route module during a production build, so the API's + // boot code runs on a machine that has no business holding a signing key. + // The serving process still refuses to start, which is where it matters. + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("NEXT_PHASE", "phase-production-build"); + + expect(() => + assertContentPreviewConfig({ + contentTypes: previewable, + secret: undefined, + }), + ).not.toThrow(); + expect(warn).toHaveBeenCalled(); + + vi.unstubAllEnvs(); + }); + + it("still refuses a production process that is actually serving", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("NEXT_PHASE", "phase-production-server"); + + expect(() => + assertContentPreviewConfig({ + contentTypes: previewable, + secret: undefined, + }), + ).toThrow(/CONTENT_PREVIEW_SECRET/); + + vi.unstubAllEnvs(); + }); + + it("warns instead of throwing outside production", () => { + // `pnpm dev` should still start. Preview itself stays switched off - the + // routes fail closed - but a local database is not a reason to refuse boot. + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + expect(() => + assertContentPreviewConfig({ + contentTypes: previewable, + isProduction: false, + secret: undefined, + }), + ).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("CONTENT_PREVIEW_SECRET"), + ); + }); +}); diff --git a/packages/vitnode/src/content/server/preview-config.ts b/packages/vitnode/src/content/server/preview-config.ts new file mode 100644 index 000000000..7c66cdb23 --- /dev/null +++ b/packages/vitnode/src/content/server/preview-config.ts @@ -0,0 +1,124 @@ +import type { RegisteredContentType } from "../registry"; + +import { + CONFIG, + CONTENT_PREVIEW_SECRET_MIN_BYTES, + INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET, + isSecureContentPreviewSecret, +} from "../../lib/config"; +import { ContentEngineError } from "../errors"; + +/** + * The sentence a person needs to fix an unusable preview secret. + * + * `null` when the secret is fine. Three distinct reasons rather than one, + * because "you have not set it" and "you set it to twelve characters" call for + * different reactions, and a single "misconfigured" would hide which. + */ +export const contentPreviewSecretProblem = ( + secret: null | string | undefined, +): null | string => { + if (isSecureContentPreviewSecret(secret)) return null; + + if (secret === undefined || secret === null || secret === "") { + return "CONTENT_PREVIEW_SECRET is not set."; + } + + if (secret === INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET) { + return "CONTENT_PREVIEW_SECRET is still the built-in placeholder, which is published in the VitNode source."; + } + + return `CONTENT_PREVIEW_SECRET is shorter than ${CONTENT_PREVIEW_SECRET_MIN_BYTES} bytes.`; +}; + +/** Whether a configured origin is a URL the preview link builder can use. */ +const originProblem = (name: string, read: () => URL): null | string => { + try { + read(); + + return null; + } catch { + return `${name} is not a valid absolute URL, so preview links cannot be built.`; + } +}; + +/** + * Everything standing between this install and a working preview link. + * + * Both halves matter and both are checked here rather than at the point of use: + * an unusable secret means anyone can mint their own token, and an unparseable + * `NEXT_PUBLIC_WEB_URL` means the link that comes back is not a link. + */ +export const contentPreviewConfigProblems = ( + secret: null | string | undefined, +): string[] => { + const problems = [ + contentPreviewSecretProblem(secret), + originProblem("NEXT_PUBLIC_WEB_URL", () => CONFIG.web), + originProblem("NEXT_PUBLIC_API_URL", () => CONFIG.api), + ]; + + return problems.filter((problem): problem is string => problem !== null); +}; + +const HOW_TO_FIX = + "Generate one with `openssl rand -base64 32` (or `node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64'))\"`) and set it on every process that serves the API."; + +/** + * Whether this process is `next build` collecting page data rather than a + * server about to answer requests. + * + * Next imports every route module during a production build, so the API's boot + * code runs there too - and a build machine has no business holding a runtime + * signing secret. Failing the build would push every install to bake its + * secrets into an image, which is a worse outcome than the one being prevented. + * The serving process still refuses to start, which is where it matters. + */ +const isBuildPhase = (): boolean => + process.env.NEXT_PHASE === "phase-production-build"; + +/** + * Refuses to boot a production install whose preview links would be forgeable. + * + * Called once, after every plugin's content types are known, because "is + * preview enabled anywhere" is not answerable before that. An install with no + * previewable content type is unaffected - there is nothing to sign. + * + * **Production refuses to start; development starts with preview switched + * off.** The reasoning is the same in both cases and only the blast radius + * differs: a signature is the *entire* access control on a preview link, so a + * well-known secret is not a warning, it is unpublished content served to + * anyone who reads the VitNode source. Failing at deploy time is far kinder + * than shipping a feature that quietly hands drafts out; failing at `pnpm dev` + * time would be rude, so there the routes fail closed instead and say why. + */ +export const assertContentPreviewConfig = ({ + contentTypes, + isProduction = process.env.NODE_ENV === "production" && !isBuildPhase(), + secret, +}: { + contentTypes: RegisteredContentType[]; + isProduction?: boolean; + secret: null | string | undefined; +}): void => { + const previewable = contentTypes.filter( + entry => entry.definition.editorial.preview.enabled, + ); + if (previewable.length === 0) return; + + const problems = contentPreviewConfigProblems(secret); + if (problems.length === 0) return; + + const names = previewable.map(entry => entry.definition.id).join(", "); + const message = `${names} ${previewable.length === 1 ? "has" : "have"} \`editorial.preview\` enabled, but preview is not safe to serve: ${problems.join(" ")} ${HOW_TO_FIX}`; + + if (isProduction) throw new ContentEngineError(message); + + // Not fatal outside a serving production process, but not silent either: + // without this the only symptom is a 503 from a button somebody clicks three + // days later. + // eslint-disable-next-line no-console + console.warn( + `[Content Engine] ${message} Preview stays disabled until then.`, + ); +}; diff --git a/packages/vitnode/src/content/server/preview-route.test.ts b/packages/vitnode/src/content/server/preview-route.test.ts new file mode 100644 index 000000000..01fee88a3 --- /dev/null +++ b/packages/vitnode/src/content/server/preview-route.test.ts @@ -0,0 +1,310 @@ +// @vitest-environment node +import { OpenAPIHono } from "@hono/zod-openapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testEditorialPostContentType } from "@/tests/content-fixtures"; + +import type { ContentRevisionSnapshot } from "../revisions"; + +import { INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET } from "../../lib/config"; +import { createContentModel } from "./model"; +import { createContentPreviewToken } from "./preview-token"; +import { buildContentPublicRoutes } from "./public-routes"; + +const PLUGIN_ID = "@vitnode/example"; +// Long enough to be a real signing key: the preview routes fail closed on a +// secret that is missing, well-known or under 32 bytes, so a short one here +// would test the guard rather than the route. +const SECRET = "unit-test-content-preview-secret-0123456789"; + +const posts = createContentModel(testEditorialPostContentType); + +const snapshot = ( + overrides?: Partial, +): ContentRevisionSnapshot => ({ + contentTypeId: testEditorialPostContentType.id, + createdAt: "2026-08-01T09:00:00.000Z", + fields: { + excerpt: "Not published yet", + slug: "hello-world", + title: "Hello world", + // Private: absent from `publicApi.fields`, so it must never reach a body. + views: 4242, + }, + id: 7, + publication: { publishedAt: null, status: "draft" }, + schemaVersion: 1, + updatedAt: "2026-08-02T09:00:00.000Z", + version: 3, + ...overrides, +}); + +/** + * Mounts the generated public routes with the editorial service and the + * database stubbed. + * + * No session middleware and no admin context: the request arrives exactly as an + * anonymous reviewer's would, which is the only way this route is ever used. + */ +const harness = ({ secret = SECRET }: { secret?: string } = {}) => { + const findById = vi.fn(); + const selections: Record[] = []; + const liveRows: Record[] = []; + + const db = { + select: (selection: Record) => { + selections.push(selection); + + return { + from: () => ({ + where: () => ({ limit: async () => Promise.resolve(liveRows) }), + }), + }; + }, + }; + + vi.spyOn(posts, "editorialService", "get").mockReturnValue( + () => ({ revisions: { findById } }) as never, + ); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("db", db as never); + c.set("core", { contentPreviewSecret: secret } as never); + await next(); + }); + for (const { handler, route } of buildContentPublicRoutes(posts, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, findById, liveRows, selections }; +}; + +const mint = (overrides?: { itemId?: number; revisionId?: number }) => + createContentPreviewToken({ + definition: testEditorialPostContentType, + itemId: overrides?.itemId ?? 7, + pluginId: PLUGIN_ID, + revisionId: overrides?.revisionId ?? 42, + secret: SECRET, + version: 3, + }).token; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("the public preview route", () => { + it("returns an unpublished record to a caller with no session", async () => { + const { app, findById } = harness(); + findById.mockResolvedValue({ snapshot: snapshot() }); + + const res = await app.request(`/preview/${mint()}`); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + excerpt: "Not published yet", + publishedAt: null, + slug: "hello-world", + title: "Hello world", + }); + }); + + it("never returns a private field", async () => { + // The fixture's `views` is deliberately absent from `publicApi.fields`. If + // the preview projected the snapshot itself instead of going through + // `createContentPublicProjector`, this is the test that would catch it. + const { app, findById } = harness(); + findById.mockResolvedValue({ snapshot: snapshot() }); + + const body = await (await app.request(`/preview/${mint()}`)).json(); + + expect(body).not.toHaveProperty("views"); + expect(JSON.stringify(body)).not.toContain("4242"); + }); + + it("marks the response private and unindexable", async () => { + const { app, findById } = harness(); + findById.mockResolvedValue({ snapshot: snapshot() }); + + const res = await app.request(`/preview/${mint()}`); + + expect(res.headers.get("cache-control")).toBe("private, no-store"); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + }); + + it("asks for the revision scoped by the record in the token", async () => { + const { app, findById } = harness(); + findById.mockResolvedValue({ snapshot: snapshot() }); + + await app.request(`/preview/${mint({ itemId: 7, revisionId: 42 })}`); + + // Both arguments, always: the revisions table is shared, so a revision id + // on its own proves nothing about which record it belongs to. + expect(findById).toHaveBeenCalledWith(7, 42); + }); + + it("reads the live row when the record has no revision", async () => { + const { app, findById, liveRows, selections } = harness(); + liveRows.push({ + excerpt: "Never edited since editorial was enabled", + id: 7, + publishedAt: null, + slug: "hello-world", + title: "Hello world", + }); + + const res = await app.request(`/preview/${mint({ revisionId: 0 })}`); + + expect(res.status).toBe(200); + expect(findById).not.toHaveBeenCalled(); + // Even on the live path the SELECT is the public allowlist plus the cursor, + // so a private column is never fetched in the first place. + expect(Object.keys(selections[0]).sort()).toEqual([ + "excerpt", + "id", + "publishedAt", + "slug", + "title", + ]); + }); + + it.each([ + ["a forged signature", "eyJhIjoxfQ.bm90LWEtc2lnbmF0dXJl"], + ["garbage", "not-a-token"], + ["an empty token", "%20"], + ])("answers 404 for %s", async (_name, token) => { + const { app } = harness(); + + expect((await app.request(`/preview/${token}`)).status).toBe(404); + }); + + it("answers 404 when the revision is gone", async () => { + // Pruned by retention, or the record was deleted. Same 404 as a forged + // token, deliberately - the reviewer learns nothing either way. + const { app, findById } = harness(); + findById.mockResolvedValue(null); + + expect((await app.request(`/preview/${mint()}`)).status).toBe(404); + }); + + it("answers 404 when a live-row token points at nothing", async () => { + const { app } = harness(); + + expect( + (await app.request(`/preview/${mint({ revisionId: 0 })}`)).status, + ).toBe(404); + }); + + it("answers 404 for a token signed with another secret", async () => { + const { app } = harness(); + const token = createContentPreviewToken({ + definition: testEditorialPostContentType, + itemId: 7, + pluginId: PLUGIN_ID, + revisionId: 42, + secret: "someone-elses-secret", + version: 3, + }).token; + + expect((await app.request(`/preview/${token}`)).status).toBe(404); + }); + + describe("an install that cannot protect its links", () => { + const forged = (secret: string) => + createContentPreviewToken({ + definition: testEditorialPostContentType, + itemId: 7, + pluginId: PLUGIN_ID, + revisionId: 42, + secret, + version: 3, + }).token; + + it.each([ + ["the published fallback", INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET], + ["a secret short enough to attack", "hunter2"], + ["no secret at all", ""], + ])("refuses a token forged with %s", async (_name, secret) => { + // The attack the fail-closed rule exists for: the fallback is in the + // published source, so an attacker signs `{ i: 7, r: 0 }` themselves and + // reads unpublished rows by walking the ids. The route does not honour + // *any* token while the secret is unusable, so the forgery is worthless. + const { app, findById, liveRows } = harness({ secret }); + findById.mockResolvedValue({ snapshot: snapshot() }); + liveRows.push({ id: 7, title: "Hello world" }); + + const res = await app.request(`/preview/${forged(secret)}`); + + expect(res.status).toBe(404); + // Nothing was even looked up: no oracle, and no wasted query. + expect(findById).not.toHaveBeenCalled(); + }); + + it("answers exactly like a bad token, so the misconfiguration is invisible", async () => { + const broken = harness({ + secret: INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET, + }); + const working = harness(); + + const bodies = await Promise.all([ + ( + await broken.app.request( + `/preview/${forged(INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET)}`, + ) + ).text(), + (await working.app.request("/preview/not-a-token")).text(), + ]); + + expect(new Set(bodies).size).toBe(1); + }); + }); + + it("says nothing different for any of them", async () => { + const { app, findById } = harness(); + findById.mockResolvedValue(null); + + const bodies = await Promise.all( + ["not-a-token", mint(), mint({ itemId: 999 })].map(async token => + (await app.request(`/preview/${token}`)).text(), + ), + ); + + // A distinguishable message is a record-existence oracle, which is the one + // thing a draft URL must not be. + expect(new Set(bodies).size).toBe(1); + }); +}); + +describe("route registration", () => { + it("declares no staff permission, deliberately", () => { + // The signed, expiring token *is* the authorization. Asserted rather than + // assumed, because adding one would silently break every preview link and + // removing one elsewhere must never look like this. + const routes = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID }); + const preview = routes.find( + entry => entry.route.path === "/preview/{token}", + ); + + expect(preview).toBeDefined(); + expect(preview).not.toHaveProperty("adminStaffPermission"); + }); + + it("cannot shadow a record whose slug is literally 'preview'", async () => { + const { app } = harness(); + const service = { + findById: vi.fn(), + findBySlug: vi.fn(), + findMany: vi.fn(), + }; + vi.spyOn(posts, "publicService", "get").mockReturnValue(() => service); + service.findBySlug.mockResolvedValue({ slug: "preview", title: "Preview" }); + + const res = await app.request("/preview"); + + expect(res.status).toBe(200); + expect(service.findBySlug).toHaveBeenCalledWith("preview"); + }); +}); diff --git a/packages/vitnode/src/content/server/preview-token.test.ts b/packages/vitnode/src/content/server/preview-token.test.ts new file mode 100644 index 000000000..656b47f48 --- /dev/null +++ b/packages/vitnode/src/content/server/preview-token.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testEditorialNoteContentType, + testEditorialPostContentType, +} from "../../tests/content-fixtures"; +import { + createContentPreviewToken, + verifyContentPreviewToken, +} from "./preview-token"; + +// Long enough to be a real signing key: the preview routes fail closed on a +// secret that is missing, well-known or under 32 bytes, so a short one here +// would test the guard rather than the route. +const SECRET = "unit-test-content-preview-secret-0123456789"; +const PLUGIN = "@vitnode/test"; + +const NOW = new Date("2026-08-05T10:00:00.000Z"); + +const mint = (overrides?: { + definition?: typeof testEditorialPostContentType; + itemId?: number; + now?: Date; + pluginId?: string; + revisionId?: number; + secret?: string; +}) => + createContentPreviewToken({ + definition: overrides?.definition ?? testEditorialPostContentType, + itemId: overrides?.itemId ?? 7, + now: overrides?.now ?? NOW, + pluginId: overrides?.pluginId ?? PLUGIN, + revisionId: overrides?.revisionId ?? 42, + secret: overrides?.secret ?? SECRET, + version: 3, + }); + +const verify = (token: string, now = NOW) => + verifyContentPreviewToken({ + definition: testEditorialPostContentType, + now, + pluginId: PLUGIN, + secret: SECRET, + token, + }); + +describe("createContentPreviewToken", () => { + it("expires after the content type's configured window", () => { + // The fixture asks for 30 minutes rather than the default 15, so this also + // proves the config is read rather than the constant. + expect( + testEditorialPostContentType.editorial.preview.expiresInMinutes, + ).toBe(30); + expect(mint().expiresAt.toISOString()).toBe("2026-08-05T10:30:00.000Z"); + }); + + it("binds the record and its revision", () => { + const payload = verify(mint().token); + + expect(payload).toMatchObject({ i: 7, p: PLUGIN, r: 42, ver: 3 }); + expect(payload?.t).toBe(testEditorialPostContentType.id); + }); +}); + +describe("verifyContentPreviewToken", () => { + it("accepts a fresh token", () => { + expect(verify(mint().token)).not.toBeNull(); + }); + + it("rejects it once it has expired", () => { + const { token } = mint(); + + expect(verify(token, new Date("2026-08-05T10:29:59.000Z"))).not.toBeNull(); + // No leeway at all: the boundary is the boundary. + expect(verify(token, new Date("2026-08-05T10:30:00.000Z"))).toBeNull(); + expect(verify(token, new Date("2026-08-05T11:00:00.000Z"))).toBeNull(); + }); + + it("rejects a token signed with another secret", () => { + expect(verify(mint({ secret: "other-secret" }).token)).toBeNull(); + }); + + it("rejects a token minted for another plugin", () => { + // The signature is valid - the *scope* is not. Without this check one + // signed token would work on every preview route in the install. + expect(verify(mint({ pluginId: "@vitnode/other" }).token)).toBeNull(); + }); + + it("rejects a token minted for another content type", () => { + const { token } = createContentPreviewToken({ + definition: testEditorialNoteContentType, + itemId: 7, + now: NOW, + pluginId: PLUGIN, + revisionId: 42, + secret: SECRET, + version: 3, + }); + + expect(verify(token)).toBeNull(); + }); + + it("rejects a tampered payload", () => { + const { token } = mint(); + const [, signature] = token.split("."); + const forged = Buffer.from( + JSON.stringify({ + aud: "content-preview", + exp: Math.floor(NOW.getTime() / 1000) + 3600, + i: 999, + p: PLUGIN, + r: 1, + t: testEditorialPostContentType.id, + v: 1, + ver: 1, + }), + "utf8", + ).toString("base64url"); + + expect(verify(`${forged}.${signature}`)).toBeNull(); + }); + + it.each([ + ["empty", ""], + ["garbage", "not-a-token"], + ["only a separator", "."], + ["truncated", "eyJhdWQiOiJjb250ZW50LXByZXZpZXcifQ"], + ])("rejects %s input without throwing", (_name, token) => { + expect(() => verify(token)).not.toThrow(); + expect(verify(token)).toBeNull(); + }); + + it("keeps every failure indistinguishable", () => { + // The route answers 404 for all of them, so the function must not hand it + // anything it could accidentally branch on. One value, every time. + const failures = [ + verify(""), + verify("garbage"), + verify(mint({ secret: "other" }).token), + verify(mint({ pluginId: "@vitnode/other" }).token), + verify(mint().token, new Date("2026-08-06T00:00:00.000Z")), + ]; + + expect(failures).toEqual([null, null, null, null, null]); + }); +}); diff --git a/packages/vitnode/src/content/server/preview-token.ts b/packages/vitnode/src/content/server/preview-token.ts new file mode 100644 index 000000000..45da24be4 --- /dev/null +++ b/packages/vitnode/src/content/server/preview-token.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +import type { AnyContentTypeDefinition } from "../types"; + +import { signPayload, verifySignedPayload } from "../../lib/api/signed-token"; +import { CONTENT_PREVIEW_TOKEN_VERSION } from "../const"; + +/** + * What a preview link carries, in short keys because it travels in a URL. + * + * `r` is the load-bearing one: a token is bound to **one revision**, so a + * reviewer sees the state the editor was looking at when they shared the link, + * not whatever the record has drifted to since. `0` means the record had no + * revision yet - a row that predates its content type opting into `editorial` - + * and the live row is read instead. + * + * `ver` is the row version at issue time. Nothing branches on it; it is there + * so a support conversation about "which version did they see" has an answer + * even after the revision was pruned. + */ +export const zodContentPreviewTokenPayload = z.object({ + /** Rejects a token minted for anything else that ever shares this secret. */ + aud: z.literal("content-preview"), + /** Epoch **seconds**, not milliseconds. */ + exp: z.number().int().positive(), + i: z.number().int().positive(), + p: z.string().min(1), + r: z.number().int().nonnegative(), + t: z.string().min(1), + v: z.literal(CONTENT_PREVIEW_TOKEN_VERSION), + ver: z.number().int().positive(), +}); + +export type ContentPreviewTokenPayload = z.infer< + typeof zodContentPreviewTokenPayload +>; + +export interface ContentPreviewToken { + expiresAt: Date; + token: string; +} + +/** + * Mints a preview link for one revision of one record. + * + * The expiry is absolute and has **no leeway** on the way back in. Web and API + * already need agreeing clocks for sessions to work at all, and slack on an + * expiry only ever weakens it. + */ +export const createContentPreviewToken = ({ + definition, + itemId, + now = new Date(), + pluginId, + revisionId, + secret, + version, +}: { + definition: AnyContentTypeDefinition; + itemId: number; + now?: Date; + pluginId: string; + /** `0` when the record has no revision to freeze. */ + revisionId: number; + secret: string; + version: number; +}): ContentPreviewToken => { + const expiresAt = new Date( + now.getTime() + definition.editorial.preview.expiresInMinutes * 60_000, + ); + + const payload: ContentPreviewTokenPayload = { + aud: "content-preview", + exp: Math.floor(expiresAt.getTime() / 1000), + i: itemId, + p: pluginId, + r: revisionId, + t: definition.id, + v: CONTENT_PREVIEW_TOKEN_VERSION, + ver: version, + }; + + return { expiresAt, token: signPayload(secret, payload) }; +}; + +/** + * Reads a preview link back, or returns `null`. + * + * One return value for every failure - bad signature, wrong secret, expired, + * truncated, minted for another plugin, another content type, or another + * record. The caller answers 404 for all of them, because a 401 or a 403 would + * confirm that the record exists, which is the single thing a draft URL must + * never do. + * + * The plugin and content type are checked here rather than trusted from the + * payload: the route knows which definition it is serving, and a token is only + * valid for *that* one. Without this, one signed token would work on every + * preview route in the install. + */ +export const verifyContentPreviewToken = ({ + definition, + now = new Date(), + pluginId, + secret, + token, +}: { + definition: AnyContentTypeDefinition; + now?: Date; + pluginId: string; + secret: string; + token: string; +}): ContentPreviewTokenPayload | null => { + const payload = verifySignedPayload( + secret, + token, + zodContentPreviewTokenPayload, + ); + if (!payload) return null; + + if (payload.p !== pluginId) return null; + if (payload.t !== definition.id) return null; + if (payload.exp * 1000 <= now.getTime()) return null; + + return payload; +}; diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 2e12f751c..66ef5507d 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -1,6 +1,8 @@ +import type { PgTableWithColumns, TableConfig } from "drizzle-orm/pg-core"; import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import type { @@ -9,6 +11,7 @@ import type { ContentPublicOrderableFieldName, } from "../types"; import type { ContentModel } from "./model"; +import type { ContentPreviewTokenPayload } from "./preview-token"; import type { ContentPublicService } from "./public-service"; import { buildRoute } from "../../api/lib/route"; @@ -16,16 +19,24 @@ import { zodPaginationPageInfo, zodPaginationQuery, } from "../../api/lib/with-pagination"; +import { CONFIG, isSecureContentPreviewSecret } from "../../lib/config"; import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const"; import { ContentEngineError } from "../errors"; import { publicOrderableColumns } from "../registry"; +import { verifyContentPreviewToken } from "./preview-token"; +import { + contentPublicSelection, + createContentPublicProjector, +} from "./public-service"; +import { contentSnapshotRow } from "./revision-snapshot"; /** - * The two read-only routes one public content type gets. + * The read-only routes one public content type gets. * * ```http * GET /api/{pluginId}/content/{publicApi.path}/ * GET /api/{pluginId}/content/{publicApi.path}/{slug} + * GET /api/{pluginId}/content/{publicApi.path}/preview/{token} (editorial.preview) * ``` * * No `adminStaffPermission` and no `/admin/` anywhere in the path, which is @@ -74,6 +85,59 @@ export const buildContentPublicRoutes = < message: `${label.singular} not found.`, }); + const project = createContentPublicProjector(definition); + + // Widened, not cast: the generated table type carries every column as a + // literal, which Drizzle's `.from()` overloads cannot resolve through a + // generic. This is the same parameter type `createContentPublicService` + // declares, so the assignment is checked rather than asserted. + const table: PgTableWithColumns = model.table; + + /** + * The row a preview link points at. + * + * Normally the revision's frozen snapshot, so a reviewer sees what the editor + * was looking at when they shared the link rather than whatever the record + * has drifted to since. + * + * `r === 0` is the one case that reads live: a record that predates its + * content type opting into `editorial` has no revision to freeze. It is still + * scoped to the id inside the signed token, and still projected through the + * public allowlist - only the "frozen" guarantee is unavailable, because + * there is nothing to freeze. + */ + const readPreviewRow = async ( + c: Context, + payload: ContentPreviewTokenPayload, + ): Promise> => { + if (payload.r > 0) { + const build = model.editorialService; + if (!build) return null; + + // Scoped by the record id from the token as well as the revision id: the + // revisions table is shared, so an id alone proves nothing about + // ownership - and the token's own id is the one this route trusts. + const revision = await build(c, { pluginId }).revisions.findById( + payload.i, + payload.r, + ); + + return revision ? contentSnapshotRow(revision.snapshot) : null; + } + + // Deliberately no published predicate - previewing a draft is the whole + // feature - but still only the allowlisted columns, so a private one is + // never fetched in the first place. + const [row] = await c + .get("db") + .select(contentPublicSelection(definition, model.columns)) + .from(table) + .where(eq(model.columns.id, payload.i)) + .limit(1); + + return row ?? null; + }; + const list = buildRoute({ pluginId, route: { @@ -125,6 +189,74 @@ export const buildContentPublicRoutes = < }, }); + /** + * The one public route that can return an unpublished record. + * + * Everything that makes that safe is in this handler, so it is worth reading + * as a whole: + * + * - **The token is the authorization.** Signed with HMAC-SHA256, bound to one + * plugin, one content type, one record and one revision, and expiring. No + * session is consulted, which is the point - a reviewer has no account. + * - **Every failure is the same 404.** A forged signature, an expired link, a + * token for another record and a record that never existed are + * indistinguishable. A 401 or a 403 would confirm the record exists, which + * is precisely what a draft URL must not do. + * - **The projection is the public one.** `createContentPublicProjector` is + * the same function the detail route uses, so a private field cannot be + * public here and private there. + * - **Nothing caches it.** `private, no-store` keeps it out of shared caches + * and `noindex, nofollow` keeps it out of search results, in case a link is + * pasted somewhere public. + */ + const preview = buildRoute({ + pluginId, + route: { + method: "get", + // Two segments, so it can never shadow `/{slug}` - a record whose slug is + // literally "preview" still resolves the ordinary way. + path: "/preview/{token}", + description: `Read one ${label.singular} from a signed preview link`, + request: { params: z.object({ token: z.string() }) }, + responses: { + 200: { + content: { + "application/json": { schema: schemas.publicSelectObject }, + }, + description: `${label.singular} as the link's revision recorded it`, + }, + 404: { description: "No such preview" }, + }, + }, + handler: async c => { + const secret = + c.get("core")?.contentPreviewSecret ?? CONFIG.contentPreviewSecret; + + // Fail closed, and fail *indistinguishably*. A deployment whose secret is + // missing or still the published placeholder can have its tokens forged + // by anyone, so no token is honoured at all - and the answer is the same + // 404 a bad signature gets, because "preview is misconfigured here" is + // not something an anonymous request needs to learn. + if (!isSecureContentPreviewSecret(secret)) throw notFound(); + + const payload = verifyContentPreviewToken({ + definition, + pluginId, + secret, + token: c.req.param("token"), + }); + if (!payload) throw notFound(); + + const row = await readPreviewRow(c, payload); + if (!row) throw notFound(); + + return c.json(project(row), 200, { + "Cache-Control": "private, no-store", + "X-Robots-Tag": "noindex, nofollow", + }); + }, + }); + const detail = buildRoute({ pluginId, route: { @@ -153,5 +285,11 @@ export const buildContentPublicRoutes = < }, }); - return [list, detail]; + return [ + list, + // Before `detail` for readability only - the two can never both match, so + // the order carries no meaning. + ...(definition.editorial.preview.enabled ? [preview] : []), + detail, + ]; }; diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index ca7f602e9..ab9b4cf15 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -62,6 +62,77 @@ export interface ContentPublicService { }>; } +/** + * The public projection, as a standalone function. + * + * Extracted so the preview route can use **this** rather than a second + * implementation that looks the same on the day it is written. The allowlist, + * the relation-to-`{ id }` collapse and the "drop the cursor `id` unless it was + * exposed" rule are one piece of code, so a field cannot become public on one + * route and stay private on the other. + * + * It reads nothing but the definition: no database handle, no columns, no + * joins. An exposed relation is projected from the foreign key the row already + * carries, which is what makes it impossible for one content type's allowlist + * to publish another's administrative metadata. + */ +export const createContentPublicProjector = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, +): ((row: Record) => ContentPublicSelect) => { + const publicApi = definition.publicApi; + + if (!publicApi.enabled) { + throw new ContentEngineError( + "This content type has no public API, so there is no public projection to build.", + { contentTypeId: definition.id }, + ); + } + + const exposed = publicApi.fields; + const exposesId = exposed.includes("id"); + // A `user` field is never exposable, so this is only ever relations. + const exposedRelations = new Set( + exposed.filter(name => definition.fields[name]?.kind === "relation"), + ); + + return row => { + const projected: Record = {}; + + for (const name of exposed) { + if (!exposedRelations.has(name)) { + projected[name] = row[name]; + continue; + } + + const id = row[name]; + projected[name] = typeof id === "number" ? { id } : null; + } + + if (exposesId) projected.id = row.id; + + return projected as ContentPublicSelect; + }; +}; + +/** + * The columns a public read selects: the allowlist, plus `id` for the cursor. + * + * `id` is fetched whether or not it is exposed, because pagination needs it - + * and then dropped again by the projector. A private column is never in this + * map at all, so it cannot leak through a mistake further downstream. + */ +export const contentPublicSelection = ( + definition: AnyContentTypeDefinition, + columns: Record, +): Record => ({ + id: columns.id, + ...Object.fromEntries( + definition.publicApi.fields.map(name => [name, columns[name]]), + ), +}); + /** 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; @@ -121,47 +192,13 @@ export const createContentPublicService = < const primaryCursor = columns.id as PgColumn< ColumnBaseConfig<"number", string> >; - const exposed = publicApi.fields; - const exposesId = exposed.includes("id"); - // A `user` field is never exposable, so this is only ever relations. - const exposedRelations = new Set( - exposed.filter(name => fields[name]?.kind === "relation"), - ); const searchColumns = publicApi.searchableFields.map(name => columns[name]); const orderable = publicOrderableColumns(definition); - /** Own columns, plus `id` for the cursor whether or not it is exposed. */ - const selection = (): Record => ({ - id: primaryCursor, - ...Object.fromEntries(exposed.map(name => [name, columns[name]])), - }); - - /** - * Turns one raw row into the public projection: relations collapse to - * `{ id }`, and the cursor `id` disappears unless the allowlist asked for it. - * - * The relation identifier is the foreign key already on this row, so no - * target table is read and no label is invented. - */ - const project = ( - row: Record, - ): ContentPublicSelect => { - const projected: Record = {}; - - for (const name of exposed) { - if (!exposedRelations.has(name)) { - projected[name] = row[name]; - continue; - } + const selection = (): Record => + contentPublicSelection(definition, columns); - const id = row[name]; - projected[name] = typeof id === "number" ? { id } : null; - } - - if (exposesId) projected.id = row.id; - - return projected as ContentPublicSelect; - }; + const project = createContentPublicProjector(definition); const readOne = async ( condition: SQL, diff --git a/packages/vitnode/src/content/server/revalidate-bridge.test.ts b/packages/vitnode/src/content/server/revalidate-bridge.test.ts new file mode 100644 index 000000000..aa906c594 --- /dev/null +++ b/packages/vitnode/src/content/server/revalidate-bridge.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CONTENT_REVALIDATE_PATH, + CONTENT_REVALIDATE_TIMESTAMP_HEADER, + dispatchContentRevalidation, +} from "./revalidate-bridge"; + +const input = { + contentTypeId: "example.article", + id: 7, + isPublic: true, + mode: "immediate" as const, + slugs: ["hello-world"], + wasPublic: false, +}; + +const context = (overrides?: { + cronSecret?: string; + origins?: string[]; +}): { c: Context; logged: string[] } => { + const logged: string[] = []; + const core = { + contentRevalidateOrigins: overrides?.origins, + cronSecret: overrides?.cronSecret ?? "shared-secret", + }; + + return { + c: { + get: (key: string) => + key === "core" + ? core + : key === "log" + ? { + error: async (message: string) => { + logged.push(message); + + return Promise.resolve(); + }, + } + : undefined, + } as unknown as Context, + logged, + }; +}; + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NEXT_PUBLIC_WEB_URL", "https://web.example.com"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("dispatchContentRevalidation", () => { + it("posts to the configured web origin with the shared secret", async () => { + const { c } = context(); + + const result = await dispatchContentRevalidation(c, input); + + expect(result).toEqual({ attempted: 1, delivered: 1 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`https://web.example.com${CONTENT_REVALIDATE_PATH}`); + expect((init.headers as Record).authorization).toBe( + "Bearer shared-secret", + ); + expect(JSON.parse(init.body as string)).toEqual(input); + }); + + it("stamps a timestamp so the receiver can refuse a replay", async () => { + const { c } = context(); + + await dispatchContentRevalidation(c, input); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const stamp = Number( + (init.headers as Record)[ + CONTENT_REVALIDATE_TIMESTAMP_HEADER + ], + ); + + expect(Math.abs(Date.now() - stamp)).toBeLessThan(5000); + }); + + it("posts to every configured origin independently", async () => { + const { c } = context({ + origins: ["https://a.example.com", "https://b.example.com"], + }); + + const result = await dispatchContentRevalidation(c, input); + + expect(result).toEqual({ attempted: 2, delivered: 2 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("keeps going when one origin fails", async () => { + // Separate deployments with separate caches: a stale page on one is not a + // reason for a stale page on all of them. + fetchMock + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValue(new Response(null, { status: 200 })); + + const { c } = context({ + origins: ["https://down.example.com", "https://up.example.com"], + }); + + const result = await dispatchContentRevalidation(c, input); + + expect(result).toEqual({ attempted: 2, delivered: 1 }); + }); + + it("retries once before giving up", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 500 })); + + const { c } = context(); + const result = await dispatchContentRevalidation(c, input); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).toEqual({ attempted: 1, delivered: 0 }); + }); + + it("does not retry a rejected secret", async () => { + // A 403 is a misconfiguration, and hammering it will not fix it. + fetchMock.mockResolvedValue(new Response(null, { status: 403 })); + + const { c, logged } = context(); + await dispatchContentRevalidation(c, input); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(logged.join(" ")).toContain("CRON_SECRET"); + }); + + it("never throws, whatever happens", async () => { + // Failing the queue task would retry the *publish*, which is idempotent - + // so the second run would skip the invalidation entirely. Strictly worse. + fetchMock.mockRejectedValue(new Error("network is down")); + + const { c } = context(); + + await expect(dispatchContentRevalidation(c, input)).resolves.toEqual({ + attempted: 1, + delivered: 0, + }); + }); + + it("does nothing when the mutation affects no tag", async () => { + // A draft edited into another draft touches no public response. + const result = await dispatchContentRevalidation(context().c, { + ...input, + isPublic: false, + wasPublic: false, + }); + + expect(result).toEqual({ attempted: 0, delivered: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("logs once and gives up when no origin is configured", async () => { + vi.stubEnv("NEXT_PUBLIC_WEB_URL", ""); + + const { c, logged } = context({ origins: [] }); + const result = await dispatchContentRevalidation(c, input); + + expect(result).toEqual({ attempted: 0, delivered: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(logged).toHaveLength(1); + }); +}); diff --git a/packages/vitnode/src/content/server/revalidate-bridge.ts b/packages/vitnode/src/content/server/revalidate-bridge.ts new file mode 100644 index 000000000..e8346a3ec --- /dev/null +++ b/packages/vitnode/src/content/server/revalidate-bridge.ts @@ -0,0 +1,177 @@ +import type { Context } from "hono"; + +import type { + ContentInvalidationInput, + ContentInvalidationMode, +} from "../cache"; + +import { CONFIG } from "../../lib/config"; +import { contentInvalidationTags } from "../cache"; + +/** Where the Route Handler is mounted in every VitNode web app. */ +export const CONTENT_REVALIDATE_PATH = "/api/vitnode/content/revalidate"; + +/** Requests older than this are refused. Replaying one is harmless anyway. */ +export const CONTENT_REVALIDATE_MAX_SKEW_MS = 5 * 60 * 1000; + +export const CONTENT_REVALIDATE_TIMESTAMP_HEADER = "x-vitnode-timestamp"; + +/** Two attempts, then give up and let the tag live out its own lifetime. */ +const ATTEMPTS = 2; +const RETRY_DELAY_MS = 250; + +export interface ContentRevalidationRequest extends ContentInvalidationInput { + mode: ContentInvalidationMode; +} + +const sleep = async (ms: number): Promise => + await new Promise(resolve => setTimeout(resolve, ms)); + +/** + * Which web origins to notify. + * + * Defaults to the one origin every install already configures for its session + * cookie and CORS. An install that serves several web apps from one API + * overrides it, and each is posted independently. + */ +const originsFor = (c: Context): string[] => { + const configured = c.get("core")?.contentRevalidateOrigins; + if (configured && configured.length > 0) return configured; + + try { + // `CONFIG.web` builds a `URL`, which throws on an empty or malformed + // `NEXT_PUBLIC_WEB_URL`. A bad env var must degrade to "nowhere to tell" + // and a log line - never take the queue task down with it. + return [CONFIG.web.origin]; + } catch { + return []; + } +}; + +/** + * Tells the web app to expire the tags a background mutation just invalidated. + * + * This exists because of one hard constraint: **the queue handler does not run + * in Next.** In the split deployment it is a plain `@hono/node-server` process + * where importing `next/cache` throws outright, and in the single-app + * deployment it is a Route Handler where `updateTag` is unavailable. So a + * scheduled publish cannot expire a cache tag by calling a function - it has to + * ask the process that can. + * + * The alternative was to let the tag expire on its own `cacheLife`, which would + * leave an unpublished record readable for as long as that lasts. That is not a + * cache miss; it is the feature not working. + * + * **It reports rather than throws.** Every origin is tried, a failure is logged, + * and the counts come back for the caller to judge. That split matters: one + * origin being unreachable must not stop the others, but it must also not be + * hidden - so the decision about whether the delivery was good enough belongs + * to whoever can retry it, not here. + * + * `content-schedule-effects` is that caller, and it requires + * `delivered === attempted`: with several web apps behind one API, a scheduled + * unpublish that expired one cache and not the other has left a withdrawn page + * readable. `attempted: 0` means there was nothing to tell - no tag needed + * expiring, or no origin is configured - which is not a failure. + */ +export const dispatchContentRevalidation = async ( + c: Context, + input: ContentRevalidationRequest, +): Promise<{ + /** How many origins were posted to. `0` means there was nothing to tell. */ + attempted: number; + /** How many accepted it. Anything less than `attempted` is a partial. */ + delivered: number; +}> => { + if (contentInvalidationTags(input).length === 0) { + return { attempted: 0, delivered: 0 }; + } + + const origins = originsFor(c); + if (origins.length === 0) { + void log( + c, + "No web origin is configured, so nothing was told to expire its cache. Set NEXT_PUBLIC_WEB_URL, or content.revalidateOrigins for a multi-app install.", + ); + + return { attempted: 0, delivered: 0 }; + } + + const secret = c.get("core")?.cronSecret ?? CONFIG.cronJobSecret; + const body = JSON.stringify(input); + let delivered = 0; + + // One origin failing must not stop the others: they are separate deployments + // with separate caches, and a stale page on one is not a reason for a stale + // page on all of them. + for (const origin of origins) { + if (await post(c, origin, body, secret)) delivered += 1; + } + + return { attempted: origins.length, delivered }; +}; + +const post = async ( + c: Context, + origin: string, + body: string, + secret: string, +): Promise => { + for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) { + try { + const response = await fetch( + new URL(CONTENT_REVALIDATE_PATH, origin).toString(), + { + body, + headers: { + authorization: `Bearer ${secret}`, + "content-type": "application/json", + // Stamped per attempt, so a retry is not refused for being old. + [CONTENT_REVALIDATE_TIMESTAMP_HEADER]: String(Date.now()), + }, + method: "POST", + }, + ); + + if (response.ok) return true; + + // A 403 is a misconfigured secret, and retrying will not fix it. + if (response.status === 403) { + void log( + c, + `${origin} refused the revalidation: the shared secret does not match. Check CRON_SECRET on both sides.`, + ); + + return false; + } + + void log(c, `${origin} answered ${response.status}.`); + } catch (error) { + void log( + c, + `${origin} could not be reached: ${error instanceof Error ? error.message : "unknown error"}`, + ); + } + + if (attempt < ATTEMPTS) await sleep(RETRY_DELAY_MS); + } + + return false; +}; + +/** + * Logging is itself best effort: it writes to the database, so it can fail for + * the same reasons the request did. + */ +const log = async (c: Context, message: string): Promise => { + const text = `[content-revalidate] ${message}`; + + try { + await c.get("log")?.error(text); + } catch { + // The logger writes to the database, so it can fail for the same reason the + // request did. The console is the only place left. + // eslint-disable-next-line no-console + console.error(text); + } +}; diff --git a/packages/vitnode/src/content/server/revision-snapshot.test.ts b/packages/vitnode/src/content/server/revision-snapshot.test.ts new file mode 100644 index 000000000..9dff528d5 --- /dev/null +++ b/packages/vitnode/src/content/server/revision-snapshot.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testEditorialNoteContentType, + testEditorialPostContentType, +} from "@/tests/content-fixtures"; + +import type { ContentRevisionSnapshot } from "../revisions"; + +import { CONTENT_REVISION_SNAPSHOT_VERSION } from "../const"; +import { + contentRevisionSnapshot, + projectRevisionSnapshot, +} from "./revision-snapshot"; + +const row = { + createdAt: new Date("2024-01-01T00:00:00.000Z"), + excerpt: null, + id: 7, + publishedAt: new Date("2024-03-01T12:00:00.000Z"), + slug: "hello-world", + status: "published", + title: "Hello world", + updatedAt: new Date("2024-02-01T00:00:00.000Z"), + version: 4, + views: 12, +}; + +describe("contentRevisionSnapshot", () => { + const snapshot = contentRevisionSnapshot(testEditorialPostContentType, row); + + it("stamps the schema version so a future shape change is visible", () => { + expect(snapshot.schemaVersion).toBe(CONTENT_REVISION_SNAPSHOT_VERSION); + }); + + it("records every declared field", () => { + expect(Object.keys(snapshot.fields).sort()).toEqual([ + "excerpt", + "slug", + "title", + "views", + ]); + }); + + it("serialises dates as ISO strings", () => { + expect(snapshot.createdAt).toBe("2024-01-01T00:00:00.000Z"); + expect(snapshot.updatedAt).toBe("2024-02-01T00:00:00.000Z"); + expect(snapshot.publication?.publishedAt).toBe("2024-03-01T12:00:00.000Z"); + }); + + it("keeps nulls explicit rather than dropping the key", () => { + expect("excerpt" in snapshot.fields).toBe(true); + expect(snapshot.fields.excerpt).toBeNull(); + }); + + it("records the lifecycle without making it restorable", () => { + expect(snapshot.publication).toEqual({ + publishedAt: "2024-03-01T12:00:00.000Z", + status: "published", + }); + }); + + it("omits the lifecycle for a content type without publication", () => { + expect( + contentRevisionSnapshot(testEditorialNoteContentType, { + body: null, + createdAt: new Date(), + id: 1, + title: "Note", + updatedAt: new Date(), + version: 1, + }).publication, + ).toBeUndefined(); + }); + + it("survives a JSON round trip unchanged", () => { + // The whole point of flattening: a snapshot read back years later needs + // nothing but `JSON.parse`. + expect(JSON.parse(JSON.stringify(snapshot))).toEqual(snapshot); + }); + + it("is deterministic for equal states", () => { + expect(JSON.stringify(snapshot)).toBe( + JSON.stringify( + contentRevisionSnapshot(testEditorialPostContentType, row), + ), + ); + }); + + it("stores no relation label, only the identifier", () => { + const withLabels = contentRevisionSnapshot(testEditorialPostContentType, { + ...row, + labels: { category: "News" }, + }); + + expect(withLabels.fields).not.toHaveProperty("labels"); + expect(withLabels.fields).not.toHaveProperty("category"); + }); + + it("refuses to stringify an unrecognised value", () => { + // A future column type must not smuggle "[object Object]" into a snapshot + // and have a restore write it back as a real value. + const odd = contentRevisionSnapshot(testEditorialPostContentType, { + ...row, + title: { nested: true }, + }); + + expect(odd.fields.title).toBeNull(); + }); +}); + +describe("projectRevisionSnapshot", () => { + const base: ContentRevisionSnapshot = { + contentTypeId: "test.editorial", + createdAt: "2024-01-01T00:00:00.000Z", + fields: { excerpt: null, slug: "hello", title: "Hello", views: 1 }, + id: 7, + schemaVersion: 1, + updatedAt: "2024-01-01T00:00:00.000Z", + version: 1, + }; + + it("projects only currently declared fields", () => { + const projected = projectRevisionSnapshot(testEditorialPostContentType, { + ...base, + fields: { ...base.fields, sinceRemoved: "gone" }, + }); + + expect(projected).not.toHaveProperty("sinceRemoved"); + expect(Object.keys(projected).sort()).toEqual([ + "excerpt", + "slug", + "title", + "views", + ]); + }); + + it("omits a field the snapshot never carried", () => { + const projected = projectRevisionSnapshot(testEditorialPostContentType, { + ...base, + fields: { title: "Hello" }, + }); + + // Absent, not null: the record keeps whatever it holds today. + expect(projected).toEqual({ title: "Hello" }); + }); + + it("never projects a generated column", () => { + const projected = projectRevisionSnapshot(testEditorialPostContentType, { + ...base, + publication: { publishedAt: null, status: "published" }, + }); + + for (const name of [ + "id", + "version", + "status", + "publishedAt", + "createdAt", + ]) { + expect(projected).not.toHaveProperty(name); + } + }); +}); diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts new file mode 100644 index 000000000..8f6767044 --- /dev/null +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -0,0 +1,129 @@ +import type { + ContentRevisionSnapshot, + ContentSnapshotValue, +} from "../revisions"; +import type { AnyContentTypeDefinition } from "../types"; + +import { CONTENT_REVISION_SNAPSHOT_VERSION } from "../const"; + +const toIso = (value: unknown): string => { + if (value instanceof Date) return value.toISOString(); + if (typeof value === "string") return value; + + return new Date(0).toISOString(); +}; + +const toIsoOrNull = (value: unknown): null | string => { + if (value === null || value === undefined) return null; + + return toIso(value); +}; + +/** + * One column value, flattened to something `JSON.parse` gives back unchanged. + * + * A `Date` becomes an ISO string; a relation or user is already the foreign key + * integer; everything else is a primitive. Anything unrecognised becomes `null` + * rather than being stringified, so a future column type cannot smuggle + * `"[object Object]"` into a snapshot and have a restore write it back. + */ +const toSnapshotValue = (value: unknown): ContentSnapshotValue => { + if (value === null || value === undefined) return null; + if (value instanceof Date) return value.toISOString(); + + const type = typeof value; + if (type === "boolean" || type === "number" || type === "string") { + return value as ContentSnapshotValue; + } + + return null; +}; + +/** + * Builds the snapshot stored on a revision. + * + * Deterministic: field names are emitted in the content type's own declaration + * order, so two equal states serialise byte for byte and a diff test is a table + * rather than a set comparison. + * + * The publication columns are recorded but are *not* restorable - they are + * absent from `schemas.update`, so a restore structurally cannot move them. + * They are here so the history can show what the lifecycle was at the time. + */ +export const contentRevisionSnapshot = ( + definition: AnyContentTypeDefinition, + row: object, +): ContentRevisionSnapshot => { + const values = row as Record; + const fields: Record = {}; + + for (const name of Object.keys(definition.fields)) { + fields[name] = toSnapshotValue(values[name]); + } + + const snapshot: ContentRevisionSnapshot = { + contentTypeId: definition.id, + createdAt: toIso(values.createdAt), + fields, + id: typeof values.id === "number" ? values.id : 0, + schemaVersion: CONTENT_REVISION_SNAPSHOT_VERSION, + updatedAt: toIso(values.updatedAt), + version: typeof values.version === "number" ? values.version : 1, + }; + + if (definition.publication.enabled) { + snapshot.publication = { + publishedAt: toIsoOrNull(values.publishedAt), + status: typeof values.status === "string" ? values.status : "draft", + }; + } + + return snapshot; +}; + +/** + * A snapshot, shaped like the row it was taken from. + * + * Flat rather than nested, because the public projector reads a row by column + * name and must not learn that a preview exists - one projection, one + * allowlist, no second code path where a private field could slip through. + * + * Timestamps stay ISO strings. Hono serialises a `Date` to exactly that, so the + * response body is byte-identical to a live read. + */ +export const contentSnapshotRow = ( + snapshot: ContentRevisionSnapshot, +): Record => ({ + ...snapshot.fields, + createdAt: snapshot.createdAt, + id: snapshot.id, + publishedAt: snapshot.publication?.publishedAt ?? null, + updatedAt: snapshot.updatedAt, +}); + +/** + * The part of a snapshot a restore may apply: currently declared fields only. + * + * A field the content type has since dropped is ignored rather than rejected - + * the snapshot is a record of the past, and the past is allowed to mention + * things that no longer exist. A field added since is simply absent, so the + * record keeps whatever it holds now. + * + * The generated columns are never projected. `id`, `version` and the timestamps + * belong to the row's identity, and `status`/`publishedAt` are lifecycle state + * that only publish and unpublish may move. + */ +export const projectRevisionSnapshot = ( + definition: AnyContentTypeDefinition, + snapshot: ContentRevisionSnapshot, +): Record => { + const projected: Record = {}; + + for (const name of Object.keys(definition.fields)) { + if (!(name in snapshot.fields)) continue; + + projected[name] = snapshot.fields[name]; + } + + return projected; +}; diff --git a/packages/vitnode/src/content/server/revisions-model.test.ts b/packages/vitnode/src/content/server/revisions-model.test.ts new file mode 100644 index 000000000..88ace8049 --- /dev/null +++ b/packages/vitnode/src/content/server/revisions-model.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import { testEditorialPostContentType } from "@/tests/content-fixtures"; + +import { + CONTENT_REVISIONS_MAX_PAGE_SIZE, + createContentRevisionsModel, +} from "./revisions-model"; + +const PLUGIN_ID = "@vitnode/example"; + +/** One history row, only as detailed as the pagination needs. */ +const revision = (version: number) => ({ + actorName: null, + actorType: "staff" as const, + actorUserId: 1, + changedFields: [], + createdAt: new Date("2026-08-01T00:00:00.000Z"), + id: 1000 + version, + operation: "update" as const, + restoredFromRevisionId: null, + version, +}); + +/** + * A chainable Drizzle stand-in that records the requested limit and hands back + * as many rows as the fake table has, newest first. + */ +const harness = ({ total }: { total: number }) => { + const requested: { limit: number }[] = []; + const conditions: unknown[] = []; + + // Versions `total` down to 1, which is the order the real index scan gives. + const all = Array.from({ length: total }, (_, index) => + revision(total - index), + ); + + const db = { + select: () => { + let where: unknown; + + const builder = { + from: () => builder, + leftJoin: () => builder, + limit: async (value: number) => { + requested.push({ limit: value }); + conditions.push(where); + + // The cursor is exclusive, so the stub applies it that way too. + const cursor = cursorOf(where); + const rows = + cursor === null ? all : all.filter(entry => entry.version < cursor); + + return await Promise.resolve(rows.slice(0, value)); + }, + orderBy: () => builder, + where: (value: unknown) => { + where = value; + + return builder; + }, + }; + + return builder; + }, + }; + + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as unknown as Context; + + return { + model: createContentRevisionsModel({ + c, + definition: testEditorialPostContentType, + pluginId: PLUGIN_ID, + }), + requested, + }; +}; + +/** + * Reads the cursor value back out of the condition the model built. + * + * The model passes it as a bound parameter, so it turns up in the SQL's + * `queryChunks` as a plain number - which is enough to make the stub behave + * like a real exclusive `WHERE version < $cursor`. + */ +const cursorOf = (condition: unknown): null | number => { + const walk = (value: unknown): unknown[] => + value !== null && typeof value === "object" && "queryChunks" in value + ? (value.queryChunks as unknown[]).flatMap(walk) + : [value]; + + const params = walk(condition) + .map(chunk => (chunk as null | { value?: unknown })?.value) + .filter((value): value is number => typeof value === "number"); + + // The scope predicate contributes the item id; the cursor is the last one. + return params.length > 1 ? (params.at(-1) ?? null) : null; +}; + +describe("revision pagination", () => { + it("returns the newest page first", async () => { + const { model } = harness({ total: 60 }); + + const page = await model.list(7, { limit: 25 }); + + expect(page.edges).toHaveLength(25); + expect(page.edges[0].version).toBe(60); + expect(page.edges.at(-1)?.version).toBe(36); + }); + + it("says there is more, and where it resumes", async () => { + const { model } = harness({ total: 60 }); + + const page = await model.list(7, { limit: 25 }); + + expect(page.pageInfo).toEqual({ endCursor: 36, hasNextPage: true }); + }); + + it("reads one row past the page to answer that", async () => { + // Cheaper than a COUNT, and it cannot disagree with the rows just returned. + const { model, requested } = harness({ total: 60 }); + + await model.list(7, { limit: 25 }); + + expect(requested[0].limit).toBe(26); + }); + + it("does not repeat the boundary revision on the next page", async () => { + // The bug: an inclusive `<=` cursor returns version 36 again, and a UI that + // appends shows it twice. + const { model } = harness({ total: 60 }); + + const first = await model.list(7, { limit: 25 }); + const second = await model.list(7, { + cursor: first.pageInfo.endCursor ?? undefined, + limit: 25, + }); + + expect(second.edges[0].version).toBe(35); + expect( + new Set([...first.edges, ...second.edges].map(edge => edge.id)).size, + ).toBe(50); + }); + + it("reaches every retained revision", async () => { + // The other half of the bug: the default retention is 50 and the default + // page is 25, so one page left half the history unreachable. + const { model } = harness({ total: 50 }); + + const versions: number[] = []; + let cursor: number | undefined; + let guard = 0; + + for (;;) { + const page = await model.list(7, { cursor, limit: 25 }); + versions.push(...page.edges.map(edge => edge.version)); + if (!page.pageInfo.hasNextPage || (guard += 1) > 10) break; + cursor = page.pageInfo.endCursor ?? undefined; + } + + expect(versions).toHaveLength(50); + expect(new Set(versions).size).toBe(50); + }); + + it("ends on a partial page with no next", async () => { + const { model } = harness({ total: 30 }); + + const first = await model.list(7, { limit: 25 }); + const second = await model.list(7, { + cursor: first.pageInfo.endCursor ?? undefined, + limit: 25, + }); + + expect(second.edges).toHaveLength(5); + expect(second.pageInfo.hasNextPage).toBe(false); + }); + + it("reports an empty history honestly", async () => { + const { model } = harness({ total: 0 }); + + expect(await model.list(7)).toEqual({ + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }); + }); + + it("caps the page size", async () => { + const { model, requested } = harness({ total: 500 }); + + const page = await model.list(7, { limit: 5000 }); + + expect(requested[0].limit).toBe(CONTENT_REVISIONS_MAX_PAGE_SIZE + 1); + expect(page.edges).toHaveLength(CONTENT_REVISIONS_MAX_PAGE_SIZE); + }); + + it("keeps a newer revision from shifting the page under a reader", async () => { + // A cursor on `version` is stable in a way an offset is not: a revision + // added between two requests is newer than the cursor, so page two returns + // exactly what it would have returned before. + const growing = harness({ total: 60 }); + const first = await growing.model.list(7, { limit: 25 }); + + const afterInsert = harness({ total: 61 }); + const second = await afterInsert.model.list(7, { + cursor: first.pageInfo.endCursor ?? undefined, + limit: 25, + }); + + expect(second.edges[0].version).toBe(35); + }); +}); diff --git a/packages/vitnode/src/content/server/revisions-model.ts b/packages/vitnode/src/content/server/revisions-model.ts new file mode 100644 index 000000000..dc022336a --- /dev/null +++ b/packages/vitnode/src/content/server/revisions-model.ts @@ -0,0 +1,253 @@ +import type { Context } from "hono"; + +import { and, desc, eq, lt, lte, notInArray, sql } from "drizzle-orm"; + +import type { + ContentActor, + ContentRevisionDetail, + ContentRevisionMeta, + ContentRevisionOperation, + ContentRevisionSnapshot, +} from "../revisions"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; + +import { core_content_revisions } from "../../database/content"; +import { core_users } from "../../database/users"; + +export interface ContentRevisionCaptureInput { + actor: ContentActor; + changedFields: readonly string[]; + itemId: number; + operation: ContentRevisionOperation; + restoredFromRevisionId?: number; + snapshot: ContentRevisionSnapshot; + /** The version the record holds after the mutation. */ + version: number; +} + +export interface ContentRevisionsModel { + /** + * Writes one revision and prunes past the retention window. + * + * Takes the transaction explicitly rather than defaulting to the request + * handle: a revision that is not in the same transaction as the write it + * describes is a lie waiting to happen. + */ + capture: ( + tx: ContentDatabase, + input: ContentRevisionCaptureInput, + ) => Promise; + findById: ( + itemId: number, + revisionId: number, + tx?: ContentDatabase, + ) => Promise; + latest: (itemId: number) => Promise; + /** Newest first. Metadata only - a snapshot is loaded on demand. */ + list: ( + itemId: number, + args?: { cursor?: number; limit?: number }, + ) => Promise; +} + +/** + * One page of history. + * + * `endCursor` is the **version** of the last row returned, not its id: version + * is what the query orders and filters by, it is unique per record, and it is + * strictly decreasing down the page. A revision id would be neither ordered nor + * dense once retention has pruned. + */ +export interface ContentRevisionPage { + edges: ContentRevisionMeta[]; + pageInfo: { + endCursor: null | number; + hasNextPage: boolean; + }; +} + +export const CONTENT_REVISIONS_DEFAULT_PAGE_SIZE = 25; +export const CONTENT_REVISIONS_MAX_PAGE_SIZE = 100; + +/** + * Revision reads and writes for one content type. + * + * **Every** statement in here filters on `pluginId`, `contentTypeId` *and* + * `itemId`. A revision id on its own is never enough: the table is shared by + * every editorial content type in the install, so trusting an id would let a + * request for article 7 return - or restore - a revision belonging to some + * other plugin's record entirely. + */ +export const createContentRevisionsModel = ({ + c, + definition, + pluginId, +}: { + c: Context; + definition: AnyContentTypeDefinition; + pluginId: string; +}): ContentRevisionsModel => { + const contentTypeId = definition.id; + const retention = definition.editorial.revisions.retention; + + /** The scope predicate. Not optional anywhere, which is the point. */ + const scope = (itemId: number) => + and( + eq(core_content_revisions.pluginId, pluginId), + eq(core_content_revisions.contentTypeId, contentTypeId), + eq(core_content_revisions.itemId, itemId), + ); + + const metaSelection = { + actorName: core_users.name, + actorType: core_content_revisions.actorType, + actorUserId: core_content_revisions.actorUserId, + changedFields: core_content_revisions.changedFields, + createdAt: core_content_revisions.createdAt, + id: core_content_revisions.id, + operation: core_content_revisions.operation, + restoredFromRevisionId: core_content_revisions.restoredFromRevisionId, + version: core_content_revisions.version, + }; + + return { + capture: async (tx, input) => { + const [row] = await tx + .insert(core_content_revisions) + .values({ + actorType: input.actor.type, + actorUserId: input.actor.userId, + changedFields: [...input.changedFields], + contentTypeId, + itemId: input.itemId, + operation: input.operation, + pluginId, + restoredFromRevisionId: input.restoredFromRevisionId ?? null, + snapshot: input.snapshot, + version: input.version, + }) + .returning({ id: core_content_revisions.id }); + + // Versions are strictly increasing and unique per record, so "everything + // at or below `newVersion - retention`" is exactly the set outside the + // window - one indexed range delete, in the same transaction, with no + // background job to depend on. + const keepFrom = input.version - retention; + if (keepFrom > 0) { + await tx + .delete(core_content_revisions) + .where( + and( + scope(input.itemId), + lte(core_content_revisions.version, keepFrom), + ), + ); + } + + return row.id; + }, + + findById: async (itemId, revisionId, tx) => { + const [row] = await (tx ?? c.get("db")) + .select({ ...metaSelection, snapshot: core_content_revisions.snapshot }) + .from(core_content_revisions) + .leftJoin( + core_users, + eq(core_content_revisions.actorUserId, core_users.id), + ) + // The revision id is the *last* predicate, not the only one. + .where(and(scope(itemId), eq(core_content_revisions.id, revisionId))) + .limit(1); + + return row ? row : null; + }, + + latest: async itemId => { + const [row] = await c + .get("db") + .select(metaSelection) + .from(core_content_revisions) + .leftJoin( + core_users, + eq(core_content_revisions.actorUserId, core_users.id), + ) + .where(scope(itemId)) + .orderBy(desc(core_content_revisions.version)) + .limit(1); + + return row ? row : null; + }, + + list: async (itemId, { cursor, limit } = {}) => { + const size = Math.min( + Math.max(limit ?? CONTENT_REVISIONS_DEFAULT_PAGE_SIZE, 1), + CONTENT_REVISIONS_MAX_PAGE_SIZE, + ); + + // One LEFT JOIN resolves every author in the same round trip - opening the + // history must not cost one query per row. + const rows = await c + .get("db") + .select(metaSelection) + .from(core_content_revisions) + .leftJoin( + core_users, + eq(core_content_revisions.actorUserId, core_users.id), + ) + .where( + cursor === undefined + ? scope(itemId) + : // Strictly less than, not `<=`. The cursor is the last version + // the caller already has, so including it again would repeat one + // row on every page boundary - and the AdminCP, which appends, + // would show it twice. + and(scope(itemId), lt(core_content_revisions.version, cursor)), + ) + .orderBy(desc(core_content_revisions.version)) + // One more than asked for: whether another page exists is a fact about + // the data, and reading one extra row is cheaper than a COUNT and + // cannot disagree with the rows just returned. + .limit(size + 1); + + const edges = rows.slice(0, size); + + return { + edges, + pageInfo: { + endCursor: edges.at(-1)?.version ?? null, + hasNextPage: rows.length > size, + }, + }; + }, + }; +}; + +/** + * Removes revisions whose content type is no longer registered. + * + * Retention pruning happens inline, in the write's own transaction, so this + * handles only the case that one structurally cannot: a content type that + * dropped `editorial`, or a plugin that went away. Nothing will ever write to + * those rows again, so nothing would ever prune them. + */ +export const pruneContentRevisions = async ({ + db, + knownContentTypeIds, +}: { + db: ContentDatabase; + knownContentTypeIds: string[]; +}): Promise<{ orphaned: number }> => { + const rows = await db + .delete(core_content_revisions) + .where( + // An empty list genuinely means "no content type keeps history any more". + // `notInArray` with an empty array is not valid SQL, hence the branch. + knownContentTypeIds.length === 0 + ? sql`true` + : notInArray(core_content_revisions.contentTypeId, knownContentTypeIds), + ) + .returning({ id: core_content_revisions.id }); + + return { orphaned: rows.length }; +}; diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index f7c1f7c51..07f896313 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -7,9 +7,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { testArticleContentType, testCategoryContentType, + testEditorialPostContentType, testPostContentType, } from "@/tests/content-fixtures"; +import { CONFIG } from "../../lib/config"; +import { defineContentType } from "../define"; +import { + ContentRevisionNotRestorable, + ContentScheduleError, + ContentVersionConflict, +} from "../errors"; +import { field } from "../fields"; import { createContentModel } from "./model"; import { buildContentRoutes } from "./routes"; @@ -34,8 +43,35 @@ const articles = createContentModel(testArticleContentType, { const posts = createContentModel(testPostContentType, { references: { category: () => categories.table.id }, }); +const editorialPosts = createContentModel(testEditorialPostContentType); const PLUGIN_ID = "@vitnode/example"; +const PREVIEW_SECRET = "unit-test-content-preview-secret-0123456789"; + +/** + * Previewable, with no `pathTemplate`. + * + * The other branch of the preview URL: with no page in the web app to point + * at, the link has to resolve against the **API** origin instead, and the two + * origins are not the same host in a split deployment. + */ +const noTemplateContentType = defineContentType({ + id: "test.notemplate", + tableName: "test_no_template", + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["title", "slug"], path: "no-template" }, + editorial: { enabled: true, preview: { enabled: true } }, + admin: { + label: { plural: "No Templates", singular: "No Template" }, + titleField: "title", + list: { columns: ["title"] }, + }, +}); +const noTemplatePosts = createContentModel(noTemplateContentType); const adminUser = { avatarColor: "000000", @@ -418,6 +454,16 @@ describe("generated content routes", () => { expect(res.status).toBe(409); }); + + it("still takes no body on a content type without editorial", async () => { + // The Stage 1-3 contract, unchanged. Adding a precondition to a delete + // that never had one would break every existing client. + const { app, service } = harness(); + service.delete.mockResolvedValue(row); + + expect((await app.request("/7", { method: "DELETE" })).status).toBe(200); + expect(service.delete).toHaveBeenCalledWith(7); + }); }); describe("options", () => { @@ -576,6 +622,877 @@ describe("generated content routes", () => { }); }); + describe("editorial", () => { + const editorialRow = { + createdAt: new Date("2024-01-01T00:00:00.000Z"), + excerpt: null, + id: 7, + publishedAt: null, + slug: "hello", + status: "draft" as const, + title: "Hello world", + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + version: 4, + views: 0, + }; + + const outcome = (overrides: Record = {}) => ({ + changed: true, + changedFields: ["title"], + operation: "update" as const, + previousSlug: "hello", + restoredFromRevisionId: null, + revisionId: 20, + row: editorialRow, + version: 5, + ...overrides, + }); + + const editorialHarness = ({ + allow = true, + previewSecret = PREVIEW_SECRET, + }: { allow?: boolean; previewSecret?: string } = {}) => { + const emitted: Harness["emitted"] = []; + const searched: unknown[] = []; + const editorial = { + create: vi.fn(), + delete: vi.fn(), + publish: vi.fn(), + restore: vi.fn(), + revisions: { findById: vi.fn(), latest: vi.fn(), list: vi.fn() }, + schedules: { + cancel: vi.fn(), + listForItem: vi.fn(), + pendingForItem: vi.fn(), + recordError: vi.fn(), + schedule: vi.fn(), + }, + unpublish: vi.fn(), + update: vi.fn(), + }; + + permissionGranted = allow; + // `editorialService` is `((c, opts) => Service) | undefined` on + // `ContentModel`, and `spyOn` cannot pick an overload through the union - + // so the model is viewed as the non-optional shape for the stub. + const spied = editorialPosts as unknown as { + editorialService: ( + c: Context, + options: { pluginId: string }, + ) => typeof editorial; + }; + vi.spyOn(spied, "editorialService").mockReturnValue(editorial); + + // The preview route reads the row through the ordinary service, so it + // gets a 404 for a record that is not there before it mints anything. + 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(), + }; + vi.spyOn(editorialPosts, "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("search", { + delete: async () => { + searched.push("delete"); + + return Promise.resolve(); + }, + index: async (document: unknown) => { + searched.push(document); + + return Promise.resolve(); + }, + } as unknown as Context["var"]["search"]); + c.set("log", { + error: async () => Promise.resolve(), + } as unknown as Context["var"]["log"]); + c.set("admin", allow ? { user: adminUser } : null); + c.set("core", { + // A real one by default. Preview refuses to mint a link on a + // deployment whose secret is missing, well-known or under 32 bytes. + contentPreviewSecret: previewSecret, + hasCronAdapter: true, + } as never); + c.set("user", null); + await next(); + }); + + for (const { handler, route } of buildContentRoutes(editorialPosts, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, editorial, emitted, service }; + }; + + /** The same, for the content type with no `preview.pathTemplate`. */ + const previewOnlyHarness = () => { + permissionGranted = true; + + const service = { findById: vi.fn() }; + vi.spyOn(noTemplatePosts, "service").mockReturnValue(service as never); + vi.spyOn( + noTemplatePosts as unknown as { + editorialService: () => { revisions: { latest: () => unknown } }; + }, + "editorialService", + ).mockReturnValue({ + revisions: { latest: vi.fn().mockResolvedValue(null) }, + }); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: adminUser }); + c.set("core", { contentPreviewSecret: PREVIEW_SECRET } as never); + c.set("user", null); + await next(); + }); + for (const { handler, route } of buildContentRoutes(noTemplatePosts, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, service }; + }; + + describe("update envelope", () => { + it("requires an expected version", async () => { + const { app, editorial } = editorialHarness(); + + const res = await app.request("/7", { + method: "PUT", + ...json({ values: { title: "Changed" } }), + }); + + expect(res.status).toBe(400); + expect(editorial.update).not.toHaveBeenCalled(); + }); + + it("rejects the bare body a Stage 1-3 route accepts", async () => { + const { app } = editorialHarness(); + + const res = await app.request("/7", { + method: "PUT", + ...json({ title: "Changed" }), + }); + + expect(res.status).toBe(400); + }); + + it("passes the version and the values through", async () => { + const { app, editorial } = editorialHarness(); + editorial.update.mockResolvedValue(outcome()); + + const res = await app.request("/7", { + method: "PUT", + ...json({ expectedVersion: 4, values: { title: "Changed" } }), + }); + + expect(res.status).toBe(200); + expect(editorial.update).toHaveBeenCalledWith( + 7, + { title: "Changed" }, + expect.objectContaining({ expectedVersion: 4 }), + ); + }); + + it("records the signed-in admin as the actor", async () => { + const { app, editorial } = editorialHarness(); + editorial.update.mockResolvedValue(outcome()); + + await app.request("/7", { + method: "PUT", + ...json({ expectedVersion: 4, values: { title: "Changed" } }), + }); + + expect(editorial.update).toHaveBeenCalledWith( + 7, + expect.anything(), + expect.objectContaining({ actor: { type: "staff", userId: 1 } }), + ); + }); + + it("emits `updated` once and nothing on a no-op", async () => { + const { app, editorial, emitted } = editorialHarness(); + editorial.update.mockResolvedValue( + outcome({ changed: false, changedFields: [], revisionId: null }), + ); + + await app.request("/7", { + method: "PUT", + ...json({ expectedVersion: 4, values: { title: "Hello world" } }), + }); + + expect(emitted).toEqual([]); + }); + }); + + describe("version conflict", () => { + it("answers 409 with a machine-readable body", async () => { + const { app, editorial } = editorialHarness(); + editorial.update.mockRejectedValue( + new ContentVersionConflict({ + contentTypeId: "test.editorial", + currentVersion: 9, + expectedVersion: 4, + itemId: 7, + }), + ); + + const res = await app.request("/7", { + method: "PUT", + ...json({ expectedVersion: 4, values: { title: "Changed" } }), + }); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + code: "CONTENT_VERSION_CONFLICT", + contentTypeId: "test.editorial", + currentVersion: 9, + expectedVersion: 4, + itemId: 7, + }); + }); + + it("says nothing about the database on a unique clash", async () => { + const { app, editorial } = editorialHarness(); + editorial.update.mockRejectedValue( + Object.assign(new Error("duplicate key value violates ..."), { + code: "23505", + }), + ); + + const res = await app.request("/7", { + method: "PUT", + ...json({ expectedVersion: 4, values: { title: "Changed" } }), + }); + + expect(res.status).toBe(409); + const body = (await res.json()) as Record; + expect(body.code).toBe("CONTENT_UNIQUE_CONFLICT"); + expect(JSON.stringify(body)).not.toMatch(/duplicate key/); + }); + }); + + describe("revision history", () => { + const revisionPage = ( + pageInfo: { endCursor: null | number; hasNextPage: boolean } = { + endCursor: 5, + hasNextPage: false, + }, + ) => ({ + edges: [ + { + actorName: "Test", + actorType: "staff", + actorUserId: 1, + changedFields: ["title"], + createdAt: new Date("2024-01-02T00:00:00.000Z"), + id: 20, + operation: "update", + restoredFromRevisionId: null, + version: 5, + }, + ], + pageInfo, + }); + + it("returns metadata only", async () => { + const { app, editorial } = editorialHarness(); + editorial.revisions.list.mockResolvedValue(revisionPage()); + + const res = await app.request("/7/revisions"); + const body = (await res.json()) as { edges: unknown[] }; + + expect(res.status).toBe(200); + expect(body.edges).toHaveLength(1); + // No snapshot in the list payload - opening the history must not drag + // every historical version of a long article across the wire. + expect(body.edges[0]).not.toHaveProperty("snapshot"); + }); + + it("says whether there is another page, and where it starts", async () => { + const { app, editorial } = editorialHarness(); + editorial.revisions.list.mockResolvedValue( + revisionPage({ endCursor: 5, hasNextPage: true }), + ); + + const res = await app.request("/7/revisions"); + + expect(await res.json()).toMatchObject({ + pageInfo: { endCursor: 5, hasNextPage: true }, + }); + }); + + it("passes the cursor and page size through as numbers", async () => { + const { app, editorial } = editorialHarness(); + editorial.revisions.list.mockResolvedValue(revisionPage()); + + await app.request("/7/revisions?cursor=5&first=10"); + + expect(editorial.revisions.list).toHaveBeenCalledWith(7, { + cursor: 5, + limit: 10, + }); + }); + + it("refuses a page size past the cap", async () => { + // Validated by the route schema rather than clamped silently: a client + // asking for 5000 has misunderstood something, and a 400 says so. + const { app, editorial } = editorialHarness(); + editorial.revisions.list.mockResolvedValue(revisionPage()); + + expect((await app.request("/7/revisions?first=5000")).status).toBe(400); + }); + + it("refuses a cursor that is not a version", async () => { + const { app, editorial } = editorialHarness(); + editorial.revisions.list.mockResolvedValue(revisionPage()); + + expect((await app.request("/7/revisions?cursor=0")).status).toBe(400); + expect((await app.request("/7/revisions?cursor=abc")).status).toBe(400); + }); + + it("loads one snapshot on demand", async () => { + const { app, editorial } = editorialHarness(); + editorial.revisions.findById.mockResolvedValue({ + actorName: null, + actorType: "system", + actorUserId: null, + changedFields: [], + createdAt: new Date(), + id: 20, + operation: "create", + restoredFromRevisionId: null, + snapshot: { fields: { title: "Hello" } }, + version: 1, + }); + + const res = await app.request("/7/revisions/20"); + + expect(res.status).toBe(200); + expect(editorial.revisions.findById).toHaveBeenCalledWith(7, 20); + }); + + it("404s a revision that is not this record's", async () => { + const { app, editorial } = editorialHarness(); + editorial.revisions.findById.mockResolvedValue(null); + + expect((await app.request("/7/revisions/20")).status).toBe(404); + }); + }); + + describe("delete", () => { + it("requires the version the person was looking at", async () => { + const { app, editorial } = editorialHarness(); + editorial.delete.mockResolvedValue( + outcome({ changedFields: [], operation: "delete" }), + ); + + const res = await app.request("/7", { + method: "DELETE", + ...json({ expectedVersion: 4 }), + }); + + expect(res.status).toBe(200); + expect(editorial.delete).toHaveBeenCalledWith( + 7, + expect.objectContaining({ expectedVersion: 4 }), + ); + }); + + it("refuses a delete that does not say which version", async () => { + // Optional would defeat the point: the client that forgot is exactly + // the client with a stale row. + const { app } = editorialHarness(); + + expect( + (await app.request("/7", { method: "DELETE", ...json({}) })).status, + ).toBe(400); + }); + + it("answers a structured 409 when the record moved", async () => { + const { app, editorial } = editorialHarness(); + editorial.delete.mockRejectedValue( + new ContentVersionConflict({ + contentTypeId: "test.editorial", + currentVersion: 5, + expectedVersion: 4, + itemId: 7, + }), + ); + + const res = await app.request("/7", { + method: "DELETE", + ...json({ expectedVersion: 4 }), + }); + + expect(res.status).toBe(409); + // The same envelope update and restore use, so the AdminCP tells + // "somebody saved first" from "still referenced" without reading prose. + expect(await res.json()).toEqual({ + code: "CONTENT_VERSION_CONFLICT", + contentTypeId: "test.editorial", + currentVersion: 5, + expectedVersion: 4, + itemId: 7, + }); + }); + + it("answers 404 for a record that is already gone", async () => { + const { app, editorial } = editorialHarness(); + editorial.delete.mockResolvedValue(null); + + expect( + ( + await app.request("/7", { + method: "DELETE", + ...json({ expectedVersion: 4 }), + }) + ).status, + ).toBe(404); + }); + + it("still maps a restricted foreign key to 409", async () => { + const { app, editorial } = editorialHarness(); + editorial.delete.mockRejectedValue( + Object.assign( + new Error( + 'update or delete on table "test_editorial_posts" violates foreign key constraint "fk_comments_post"', + ), + { code: "23503" }, + ), + ); + + const res = await app.request("/7", { + method: "DELETE", + ...json({ expectedVersion: 4 }), + }); + + expect(res.status).toBe(409); + // The constraint name and the table name stay on the server. + expect(await res.text()).not.toMatch(/fk_comments_post/); + }); + }); + + describe("restore", () => { + it("restores and reports what changed", async () => { + const { app, editorial } = editorialHarness(); + editorial.restore.mockResolvedValue( + outcome({ operation: "restore", restoredFromRevisionId: 3 }), + ); + + const res = await app.request("/7/revisions/3/restore", { + method: "POST", + ...json({ expectedVersion: 4 }), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ changed: true }); + expect(editorial.restore).toHaveBeenCalledWith( + 7, + 3, + expect.objectContaining({ expectedVersion: 4 }), + ); + }); + + it("emits `restored` and never also `updated`", async () => { + const { app, editorial, emitted } = editorialHarness(); + editorial.restore.mockResolvedValue( + outcome({ operation: "restore", restoredFromRevisionId: 3 }), + ); + + await app.request("/7/revisions/3/restore", { + method: "POST", + ...json({ expectedVersion: 4 }), + }); + + expect(emitted.map(entry => entry.name)).toEqual([ + "content.test.editorial.restored", + ]); + expect(emitted[0].payload).toMatchObject({ + changedFields: ["title"], + contentId: 7, + restoredFromRevisionId: 3, + revisionId: 20, + }); + }); + + it("answers 422 naming only field names", async () => { + const { app, editorial } = editorialHarness(); + editorial.restore.mockRejectedValue( + new ContentRevisionNotRestorable({ + contentTypeId: "test.editorial", + fields: ["title"], + revisionId: 3, + }), + ); + + const res = await app.request("/7/revisions/3/restore", { + method: "POST", + ...json({ expectedVersion: 4 }), + }); + + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + code: "CONTENT_REVISION_NOT_RESTORABLE", + contentTypeId: "test.editorial", + fields: ["title"], + revisionId: 3, + }); + }); + + it("requires an expected version", async () => { + const { app } = editorialHarness(); + + const res = await app.request("/7/revisions/3/restore", { + method: "POST", + ...json({}), + }); + + expect(res.status).toBe(400); + }); + }); + + describe("route generation", () => { + it("adds no revision routes without the workflow", () => { + const paths = buildContentRoutes(posts, { pluginId: PLUGIN_ID }).map( + entry => `${entry.route.method} ${entry.route.path}`, + ); + + expect(paths).not.toContain("get /{id}/revisions"); + expect(paths).not.toContain( + "post /{id}/revisions/{revisionId}/restore", + ); + }); + + it("gates every generated route on a staff permission", async () => { + // Asserted over the whole array rather than route by route, so a new + // endpoint cannot be added without one: `buildRoute` only appends the + // permission middleware when `adminStaffPermission` was supplied, so a + // 403 for every path is the observable proof. + const { app } = editorialHarness({ allow: false }); + const paths: [string, string][] = [ + ["GET", "/"], + ["GET", "/7"], + ["POST", "/"], + ["PUT", "/7"], + ["DELETE", "/7"], + ["POST", "/7/publish"], + ["POST", "/7/unpublish"], + ["GET", "/7/revisions"], + ["GET", "/7/revisions/3"], + ["POST", "/7/revisions/3/restore"], + ]; + + for (const [method, path] of paths) { + const res = await app.request(path, { + method, + ...(method === "POST" || method === "PUT" + ? json({ expectedVersion: 1, title: "Hello world", values: {} }) + : {}), + }); + + expect([method, path, res.status]).toEqual([method, path, 403]); + } + }); + + it("gates restore on can_restore, not can_edit", async () => { + const { app } = editorialHarness({ allow: false }); + + const res = await app.request("/7/revisions/3/restore", { + method: "POST", + ...json({ expectedVersion: 4 }), + }); + + expect(res.status).toBe(403); + }); + }); + + describe("preview", () => { + it("mints a link bound to the newest revision", async () => { + const { app, editorial, service } = editorialHarness(); + service.findById.mockResolvedValue({ ...editorialRow, version: 5 }); + editorial.revisions.latest.mockResolvedValue({ id: 42, version: 5 }); + + const res = await app.request("/7/preview", { method: "POST" }); + const body = (await res.json()) as { + revisionId: number; + token: string; + url: string; + version: number; + }; + + expect(res.status).toBe(200); + expect(body.revisionId).toBe(42); + expect(body.version).toBe(5); + // The fixture sets a `pathTemplate`, so the link points at the web app + // rather than the JSON endpoint - and it is absolute, because the + // AdminCP copies this value to a clipboard. + expect(body.url).toBe( + `${CONFIG.web.origin}/editorial/preview/${encodeURIComponent(body.token)}`, + ); + }); + + it("resolves the generated endpoint against the API origin", async () => { + // Different origin from the web app in a split deployment, so the two + // branches cannot share a base. `example.article` has a public API and + // preview but no `pathTemplate`. + const { app, service } = previewOnlyHarness(); + service.findById.mockResolvedValue({ ...editorialRow, version: 2 }); + + const body = (await ( + await app.request("/7/preview", { method: "POST" }) + ).json()) as { token: string; url: string }; + + expect(body.url).toBe( + `${CONFIG.api.origin}/api/${PLUGIN_ID}/content/no-template/preview/${encodeURIComponent(body.token)}`, + ); + }); + + it("percent-encodes the token into the path", async () => { + const { app, editorial, service } = editorialHarness(); + service.findById.mockResolvedValue({ ...editorialRow, version: 5 }); + editorial.revisions.latest.mockResolvedValue({ id: 42, version: 5 }); + + const body = (await ( + await app.request("/7/preview", { method: "POST" }) + ).json()) as { token: string; url: string }; + + const url = new URL(body.url); + // No double slash where the template met the origin, and the last + // segment decodes back to exactly the token that was signed. + expect(url.pathname).not.toContain("//"); + expect(decodeURIComponent(url.pathname.split("/").at(-1) ?? "")).toBe( + body.token, + ); + }); + + it("refuses to sign a link when the secret is not safe", async () => { + // 503 rather than 500: the request is fine, the deployment is missing a + // secret - and the message names the variable, because the person + // clicking the button is usually the person who can set it. + const { app, service } = editorialHarness({ + previewSecret: "too-short", + }); + service.findById.mockResolvedValue({ ...editorialRow, version: 5 }); + + const res = await app.request("/7/preview", { method: "POST" }); + + expect(res.status).toBe(503); + expect(await res.text()).toContain("CONTENT_PREVIEW_SECRET"); + }); + + it("refuses before saying whether the record exists", async () => { + // A misconfigured install must answer the same way for a record that + // is there and one that is not. + const { app, service } = editorialHarness({ + previewSecret: "too-short", + }); + service.findById.mockResolvedValue(null); + + expect( + (await app.request("/7/preview", { method: "POST" })).status, + ).toBe(503); + }); + + it("falls back to the live row when there is no revision", async () => { + // A record that predates its content type opting into editorial. It can + // still be previewed; only the frozen-snapshot guarantee is unavailable. + const { app, editorial, service } = editorialHarness(); + service.findById.mockResolvedValue({ ...editorialRow, version: 2 }); + editorial.revisions.latest.mockResolvedValue(null); + + const body = (await ( + await app.request("/7/preview", { method: "POST" }) + ).json()) as { revisionId: number; version: number }; + + expect(body).toMatchObject({ revisionId: 0, version: 2 }); + }); + + it("404s for a record that is not there, before minting anything", async () => { + const { app, editorial, service } = editorialHarness(); + service.findById.mockResolvedValue(null); + + const res = await app.request("/7/preview", { method: "POST" }); + + expect(res.status).toBe(404); + expect(editorial.revisions.latest).not.toHaveBeenCalled(); + }); + + it("needs can_view", async () => { + const { app } = editorialHarness({ allow: false }); + + expect( + (await app.request("/7/preview", { method: "POST" })).status, + ).toBe(403); + }); + + it("is absent from a content type without preview", () => { + // `testPostContentType` has a public API but no editorial block, so it + // gets no preview route at all - not a disabled one. + const paths = buildContentRoutes(articles, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + + expect(paths).not.toContain("/{id}/preview"); + }); + }); + + describe("scheduling", () => { + const future = new Date(Date.now() + 3_600_000).toISOString(); + + it("books a publication and emits one event", async () => { + const { app, editorial, emitted, service } = editorialHarness(); + service.findById.mockResolvedValue(editorialRow); + editorial.schedules.schedule.mockResolvedValue({ + generation: 1, + id: 55, + scheduledFor: new Date(future), + }); + + const res = await app.request("/7/schedule", { + method: "POST", + ...json({ action: "publish", scheduledFor: future }), + }); + + expect(res.status).toBe(200); + expect(editorial.schedules.schedule).toHaveBeenCalledWith( + expect.objectContaining({ + action: "publish", + actorUserId: adminUser.id, + itemId: 7, + }), + ); + expect(emitted.map(entry => entry.name)).toEqual([ + "content.test.editorial.scheduled", + ]); + }); + + it("404s for a record that is not there", async () => { + const { app, editorial, service } = editorialHarness(); + service.findById.mockResolvedValue(null); + + const res = await app.request("/7/schedule", { + method: "POST", + ...json({ action: "publish", scheduledFor: future }), + }); + + expect(res.status).toBe(404); + expect(editorial.schedules.schedule).not.toHaveBeenCalled(); + }); + + it("answers a refused time with a machine-readable code", async () => { + const { app, editorial, service } = editorialHarness(); + service.findById.mockResolvedValue(editorialRow); + editorial.schedules.schedule.mockRejectedValue( + new ContentScheduleError("That time has already passed.", { + code: "CONTENT_SCHEDULE_IN_PAST", + contentTypeId: testEditorialPostContentType.id, + }), + ); + + const res = await app.request("/7/schedule", { + method: "POST", + ...json({ action: "publish", scheduledFor: future }), + }); + + expect(res.status).toBe(400); + // A code, not prose: the dialog points at the date field for this one + // and shows a general error for anything else. + await expect(res.json()).resolves.toMatchObject({ + code: "CONTENT_SCHEDULE_IN_PAST", + }); + }); + + it("cancels a pending schedule and says which action it was", async () => { + const { app, editorial, emitted } = editorialHarness(); + editorial.schedules.cancel.mockResolvedValue({ action: "publish" }); + + const res = await app.request("/7/schedule/55/cancel", { + method: "POST", + }); + + expect(res.status).toBe(200); + // Scoped by the record as well as the schedule id: the table is shared. + expect(editorial.schedules.cancel).toHaveBeenCalledWith(7, 55); + expect(emitted[0]).toMatchObject({ + name: "content.test.editorial.schedule_cancelled", + payload: { action: "publish", scheduleId: 55 }, + }); + }); + + it("404s when there was nothing pending to cancel", async () => { + const { app, editorial, emitted } = editorialHarness(); + editorial.schedules.cancel.mockResolvedValue(null); + + const res = await app.request("/7/schedule/55/cancel", { + method: "POST", + }); + + expect(res.status).toBe(404); + expect(emitted).toHaveLength(0); + }); + + it("lists schedules with whether a scheduler is actually running", async () => { + // Carried on this route rather than the debug endpoint, which needs a + // permission the editor may not have - and without it the dialog would + // accept schedules that never fire. + const { app, editorial } = editorialHarness(); + editorial.schedules.listForItem.mockResolvedValue([]); + + const res = await app.request("/7/schedules"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + edges: [], + hasCronAdapter: true, + }); + }); + + it.each([ + ["POST", "/7/schedule"], + ["POST", "/7/schedule/55/cancel"], + ])("gates %s %s on can_publish", async (method, path) => { + const { app } = editorialHarness({ allow: false }); + + const res = await app.request(path, { + method, + ...(path.endsWith("/schedule") + ? json({ action: "publish", scheduledFor: future }) + : {}), + }); + + expect(res.status).toBe(403); + }); + + it("is absent from a content type without scheduling", () => { + const paths = buildContentRoutes(posts, { pluginId: PLUGIN_ID }).map( + entry => entry.route.path, + ); + + expect(paths).not.toContain("/{id}/schedule"); + expect(paths).not.toContain("/{id}/schedules"); + }); + }); + }); + 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 6926f561d..9359227f6 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -15,11 +15,30 @@ import { zodPaginationPageInfo, zodPaginationQuery, } from "../../api/lib/with-pagination"; -import { CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS } from "../const"; +import { CONFIG } from "../../lib/config"; +import { + zodContentConflict, + zodContentScheduleRejection, + zodContentUnprocessable, +} from "../conflicts"; +import { + CONTENT_ACTOR_TYPES, + CONTENT_OPTIONS_LIMIT, + CONTENT_PERMISSIONS, + CONTENT_PREVIEW_TOKEN_PLACEHOLDER, + CONTENT_REVISION_OPERATIONS, + CONTENT_SCHEDULE_ACTIONS, + CONTENT_SCHEDULE_STATUSES, +} from "../const"; import { orderableColumns } from "../registry"; +import { resolveContentActor } from "./actor"; +import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; import { withHttpErrors } from "./http-errors"; +import { contentPreviewConfigProblems } from "./preview-config"; +import { createContentPreviewToken } from "./preview-token"; import { publicationMethods } from "./publication"; +import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; import { syncContentSearch } from "./search-sync"; const zodLabels = z.record(z.string(), z.string().nullable()); @@ -107,8 +126,94 @@ export const buildContentRoutes = < }); const invalidIdentifier = { description: "Invalid identifier" }; - const uniqueConflict = { - description: "A record with these values already exists", + const editorial = definition.editorial.enabled; + + // An editorial content type answers both conflict kinds with a JSON body, so + // a client can tell "someone saved first" from "that value is taken" and act + // on the difference. Everything else keeps the plain-text 409 it has always + // returned - a Stage 1-3 route's contract does not change. + const uniqueConflict = editorial + ? jsonResponse( + zodContentConflict, + "A record with these values already exists, or the version moved", + ) + : { description: "A record with these values already exists" }; + + /** + * The editorial service, for a route that only exists when there is one. + * + * The plugin id travels in rather than being read from the request: a + * revision is stamped with its owner, and `c.get("plugin")` is the plugin + * handling the request, which is only the same thing by coincidence. + */ + const editorialService = (c: Context) => { + const build = model.editorialService; + if (!build) { + throw new HTTPException(500, { + message: "This content type has no editorial workflow.", + }); + } + + return build(c, { pluginId }); + }; + + const previewEnabled = definition.editorial.preview.enabled; + + /** + * The secret from the boot config, falling back to the env getter. + * + * The fallback matters for a direct `app.request()` in a test, which does not + * go through the global middleware that populates `core`. + */ + const previewSecret = (c: Context): string => + c.get("core")?.contentPreviewSecret ?? CONFIG.contentPreviewSecret; + + /** + * Where the link points, as something a person can paste into a browser. + * + * Absolute in both branches, and against **different origins**, because they + * are served by different processes: a `pathTemplate` names a page in the web + * app, and the generated JSON endpoint lives on the API. Assuming those share + * a host is exactly the assumption a split deployment breaks, and a relative + * path would resolve against whichever one the AdminCP happened to be on. + * + * `split`/`join` rather than `String.replace`, so a `$` in the encoded token + * cannot be read as a replacement pattern. `defineContentType` has already + * proven the template holds exactly one `{token}`. + */ + const previewUrl = (token: string): string => { + const encoded = encodeURIComponent(token); + const template = definition.editorial.preview.pathTemplate; + + return template + ? new URL( + template.split(CONTENT_PREVIEW_TOKEN_PLACEHOLDER).join(encoded), + CONFIG.web, + ).toString() + : new URL( + `/api/${pluginId}/content/${definition.publicApi.path}/preview/${encoded}`, + CONFIG.api, + ).toString(); + }; + + /** + * Refuses to mint a link the install cannot protect. + * + * 503 rather than 500: the request was fine and the code is fine, the + * deployment is missing a secret - and a service that is temporarily not + * offering a feature is what 503 means. The message names the environment + * variable, because the person clicking the button is usually the person who + * can set it. + */ + const assertPreviewIsServable = (c: Context): void => { + const problems = contentPreviewConfigProblems( + c.get("core")?.contentPreviewSecret ?? process.env.CONTENT_PREVIEW_SECRET, + ); + if (problems.length === 0) return; + + throw new HTTPException(503, { + message: `Preview is unavailable: ${problems.join(" ")}`, + }); }; const list = buildRoute({ @@ -236,12 +341,39 @@ export const buildContentRoutes = < handler: async c => { const values = await readJson(c, schemas.create); + // An editorial content type creates through the transactional service, so + // the row and its first revision land together - a record whose history + // starts at "edited" would have nothing to restore back to. + if (editorial) { + const result = await withHttpErrors( + "create", + async () => + await editorialService(c).create(values, { + actor: resolveContentActor(c), + }), + { contentTypeId: definition.id, structured: true }, + ); + + await contentEditorialEffects(c, definition, result, { pluginId }); + + return c.json(result.row, 201); + } + const row = await withHttpErrors("create", async () => model.service(c).create(values), ); // Emitted only once the write has returned, never inside a transaction. - await emitContentEvent(c, definition, "created", { contentId: row.id }); + // `pluginId` is passed on every content event, interactive or scheduled, + // so the envelope's owner is the content type's plugin rather than + // whichever module happened to invoke the helper. + await emitContentEvent( + c, + definition, + "created", + { contentId: row.id }, + { pluginId }, + ); // A new record is a draft, so this normally indexes nothing - but it is // computed from the row rather than assumed, the same way the Server @@ -256,6 +388,63 @@ export const buildContentRoutes = < }, }); + /** + * The editorial `PUT`: same path and method, one extra key in the body. + * + * `expectedVersion` sits beside `values` rather than inside it because + * `schemas.update` is a strict object of the content type's own fields, and a + * precondition is transport, not content. It is required rather than + * optional - an update that does not say which version it read is exactly the + * lost write this stage exists to prevent. + */ + const editorialUpdate = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "put", + path: "/{id}", + description: `Update a ${label.singular}`, + request: { + params: schemas.params, + body: jsonBody(schemas.updateEnvelope), + }, + responses: { + 200: jsonResponse( + schemas.selectObject, + `${label.singular} updated successfully`, + ), + 400: { description: "Invalid or empty payload" }, + 404: { description: `${label.singular} not found` }, + 409: uniqueConflict, + }, + }, + handler: async c => { + const id = identifier(c); + const { expectedVersion, values } = await readJson( + c, + schemas.updateEnvelope, + ); + + const result = await withHttpErrors( + "update", + async () => + await editorialService(c).update(id, values, { + actor: resolveContentActor(c), + expectedVersion, + }), + { contentTypeId: definition.id, itemId: id, structured: true }, + ); + if (!result) throw notFound(definition); + + // One call rather than an event branch plus a search branch: which event + // and which search operation an outcome deserves is a rule, and it is + // stated once, in `contentEditorialEffects`. + await contentEditorialEffects(c, definition, result, { pluginId }); + + return c.json(result.row, 200); + }, + }); + const update = buildRoute({ pluginId, adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, @@ -284,10 +473,16 @@ export const buildContentRoutes = < if (!result) throw notFound(definition); if (result.changedFields.length > 0) { - await emitContentEvent(c, definition, "updated", { - changedFields: result.changedFields, - contentId: result.row.id, - }); + await emitContentEvent( + c, + definition, + "updated", + { + changedFields: result.changedFields, + contentId: result.row.id, + }, + { pluginId }, + ); } // A slug change is just a rewritten `url`: the search document is keyed by @@ -327,6 +522,25 @@ export const buildContentRoutes = < }, handler: async c => { const id = identifier(c); + + if (editorial) { + const result = await withHttpErrors( + "update", + async () => + await editorialService(c)[action](id, { + actor: resolveContentActor(c), + }), + { contentTypeId: definition.id, itemId: id, structured: true }, + ); + if (!result) throw notFound(definition); + + // Still idempotent: a no-op outcome emits nothing, indexes nothing + // and - because the version did not move - leaves no revision. + await contentEditorialEffects(c, definition, result, { pluginId }); + + return c.json({ changed: result.changed, row: result.row }, 200); + } + const service = publicationMethods(definition, model.service(c)); const result = await withHttpErrors( @@ -345,6 +559,7 @@ export const buildContentRoutes = < action === "publish" && result.publishedAt ? { contentId: id, publishedAt: result.publishedAt } : { contentId: id }, + { pluginId }, ); } @@ -359,6 +574,522 @@ export const buildContentRoutes = < }, }); + const zodRevisionMeta = z.object({ + actorName: z.string().nullable(), + actorType: z.enum(CONTENT_ACTOR_TYPES), + actorUserId: z.number().nullable(), + changedFields: z.array(z.string()), + createdAt: z.union([z.date(), z.string()]), + id: z.number(), + operation: z.enum(CONTENT_REVISION_OPERATIONS), + restoredFromRevisionId: z.number().nullable(), + version: z.number(), + }); + + // `.loose()`: a snapshot is data this content type wrote, and its shape moves + // with the content type. Describing it as a closed object would make every + // field rename a breaking response schema. + const zodRevisionDetail = zodRevisionMeta.extend({ + snapshot: z.object({}).loose(), + }); + + const revisionParams = z.object({ + id: z.coerce.number(), + revisionId: z.coerce.number(), + }); + + const revisionIdentifier = (c: Context): number => { + const value = Number(c.req.param("revisionId")); + if (!Number.isInteger(value) || value <= 0) { + throw new HTTPException(400, { message: "Invalid revision identifier." }); + } + + return value; + }; + + /** + * The history cursor is a **version**, so both bounds are real constraints: + * versions start at 1, and a page larger than the cap would let one request + * pull an entire record's history. + */ + const revisionQuery = z.object({ + cursor: z.coerce.number().int().positive().optional(), + first: z.coerce + .number() + .int() + .min(1) + .max(CONTENT_REVISIONS_MAX_PAGE_SIZE) + .optional(), + }); + + const revisionList = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/revisions", + description: `History of one ${label.singular}`, + request: { params: schemas.params, query: revisionQuery }, + responses: { + 200: jsonResponse( + z.object({ + edges: z.array(zodRevisionMeta), + pageInfo: z.object({ + /** The last version on this page. Pass it back as `cursor`. */ + endCursor: z.number().nullable(), + hasNextPage: z.boolean(), + }), + }), + "Revisions, newest first", + ), + 400: { description: "Invalid query parameters" }, + }, + }, + handler: async c => { + // Parsed through the same schema the route declares, rather than + // re-derived with `Number(...)`: `?first=abc` is a 400 here and `NaN` + // there, and `NaN` would silently fall through to the default page size. + const { cursor, first } = revisionQuery.parse(c.req.query()); + + // Metadata only. Opening the history must not drag every historical + // snapshot of a long article across the wire; the detail route loads one + // on demand. + const page = await editorialService(c).revisions.list(identifier(c), { + cursor, + limit: first, + }); + + return c.json(page, 200); + }, + }); + + const revisionDetail = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/revisions/{revisionId}", + description: `One revision of a ${label.singular}, with its snapshot`, + request: { params: revisionParams }, + responses: { + 200: jsonResponse(zodRevisionDetail, "Revision found"), + 400: invalidIdentifier, + 404: { description: "Revision not found" }, + }, + }, + handler: async c => { + // Scoped by the record in the URL as well as the revision id - the + // revisions table is shared by every editorial content type in the + // install, so an id on its own proves nothing about ownership. + const revision = await editorialService(c).revisions.findById( + identifier(c), + revisionIdentifier(c), + ); + if (!revision) { + throw new HTTPException(404, { message: "Revision not found." }); + } + + return c.json(revision, 200); + }, + }); + + const restore = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.restore }, + route: { + method: "post", + path: "/{id}/revisions/{revisionId}/restore", + description: `Restore a ${label.singular} to an earlier revision`, + request: { + params: revisionParams, + body: jsonBody( + z.strictObject({ expectedVersion: z.number().int().positive() }), + ), + }, + responses: { + 200: jsonResponse( + z.object({ changed: z.boolean(), row: schemas.selectObject }), + `${label.singular} restored, or already at those values`, + ), + 400: invalidIdentifier, + 404: { description: "Revision not found" }, + 409: uniqueConflict, + 422: jsonResponse( + zodContentUnprocessable, + "The revision no longer fits this content type", + ), + }, + }, + handler: async c => { + const id = identifier(c); + const revisionId = revisionIdentifier(c); + const { expectedVersion } = await readJson( + c, + z.strictObject({ expectedVersion: z.number().int().positive() }), + ); + + const result = await withHttpErrors( + "update", + async () => + await editorialService(c).restore(id, revisionId, { + actor: resolveContentActor(c), + expectedVersion, + }), + { contentTypeId: definition.id, itemId: id, structured: true }, + ); + if (!result) { + throw new HTTPException(404, { message: "Revision not found." }); + } + + await contentEditorialEffects(c, definition, result, { pluginId }); + + return c.json({ changed: result.changed, row: result.row }, 200); + }, + }); + + /** + * Mints a preview link for the record's newest revision. + * + * `can_view` rather than `can_edit`: a preview shows what the public route + * would show, so anyone allowed to read the record in the AdminCP is already + * allowed to see this. The link itself is the credential from there on. + * + * The token is minted on demand, never handed out with the list payload - + * a table of 25 rows must not be 25 live bearer tokens for unpublished + * records sitting in a browser's memory. + */ + const previewToken = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "post", + path: "/{id}/preview", + description: `Create a preview link for one ${label.singular}`, + request: { params: schemas.params }, + responses: { + 200: jsonResponse( + z.object({ + expiresAt: z.date(), + /** `0` when the record predates its content type opting in. */ + revisionId: z.number(), + token: z.string(), + /** Absolute: the web page when one is configured, else the API. */ + url: z.url(), + version: z.number(), + }), + "Preview link created", + ), + 400: invalidIdentifier, + 404: { description: `${label.singular} not found` }, + 503: { + description: + "Preview is not configured securely on this deployment, so no link can be signed", + }, + }, + }, + handler: async c => { + // Before the lookup, so a misconfigured install answers the same way for + // a record that exists and one that does not. + assertPreviewIsServable(c); + + const id = identifier(c); + + const row = await model.service(c).findById(id); + if (!row) throw notFound(definition); + + // The newest revision is the one the editor was just looking at, and the + // last one retention will prune - so a shared link stays resolvable for + // as long as any link would. + const latest = await editorialService(c).revisions.latest(id); + const version = (row as Record).version; + + const { expiresAt, token } = createContentPreviewToken({ + definition, + itemId: id, + pluginId, + revisionId: latest?.id ?? 0, + secret: previewSecret(c), + version: latest?.version ?? (typeof version === "number" ? version : 1), + }); + + return c.json( + { + expiresAt, + revisionId: latest?.id ?? 0, + token, + url: previewUrl(token), + version: + latest?.version ?? (typeof version === "number" ? version : 1), + }, + 200, + ); + }, + }); + + const zodSchedule = z.object({ + action: z.enum(CONTENT_SCHEDULE_ACTIONS), + actorName: z.string().nullable(), + completedAt: z.union([z.date(), z.string()]).nullable(), + createdAt: z.union([z.date(), z.string()]), + createdBy: z.number().nullable(), + /** Set when the transition committed but its announcements have not. */ + effectsError: z.string().nullable(), + id: z.number(), + lastError: z.string().nullable(), + scheduledFor: z.union([z.date(), z.string()]), + status: z.enum(CONTENT_SCHEDULE_STATUSES), + }); + + /** The schedules model, for a route that only exists when there is one. */ + const schedulesModel = (c: Context) => { + const schedules = editorialService(c).schedules; + if (!schedules) { + throw new HTTPException(500, { + message: "This content type has no scheduling.", + }); + } + + return schedules; + }; + + const scheduleList = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/schedules", + description: `Pending and recent schedules for one ${label.singular}`, + request: { params: schemas.params }, + responses: { + 200: jsonResponse( + z.object({ + edges: z.array(zodSchedule), + /** + * Whether an in-process scheduler is actually running. + * + * Carried on this route rather than read from the debug endpoint, + * which needs a different permission the editor may not have. It is + * not sensitive - it says whether background jobs run - and without + * it the dialog would happily accept schedules that never fire. + */ + hasCronAdapter: z.boolean(), + }), + "Pending schedules first, then the most recent settled ones", + ), + 400: invalidIdentifier, + }, + }, + handler: async c => { + const edges = await schedulesModel(c).listForItem(identifier(c)); + + return c.json( + { edges, hasCronAdapter: c.get("core")?.hasCronAdapter ?? false }, + 200, + ); + }, + }); + + const scheduleCreate = buildRoute({ + pluginId, + // `can_publish`, not `can_edit`: booking a publication *is* publishing, just + // later. A role trusted to write drafts is not automatically trusted to put + // one on the internet at 9am on Monday. + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.publish }, + route: { + method: "post", + path: "/{id}/schedule", + description: `Schedule a ${label.singular} to publish or unpublish later`, + request: { + params: schemas.params, + body: jsonBody( + z.strictObject({ + action: z.enum(CONTENT_SCHEDULE_ACTIONS), + scheduledFor: z.iso.datetime(), + }), + ), + }, + responses: { + 200: jsonResponse( + z.object({ + generation: z.number(), + id: z.number(), + scheduledFor: z.union([z.date(), z.string()]), + }), + "Scheduled", + ), + 400: jsonResponse( + zodContentScheduleRejection, + "That time will not work", + ), + 404: { description: `${label.singular} not found` }, + }, + }, + handler: async c => { + const id = identifier(c); + const { action, scheduledFor } = await readJson( + c, + z.strictObject({ + action: z.enum(CONTENT_SCHEDULE_ACTIONS), + scheduledFor: z.iso.datetime(), + }), + ); + + // Checked before anything is written: scheduling a publication for a + // record that is not there would be a row nothing can ever act on. + const row = await model.service(c).findById(id); + if (!row) throw notFound(definition); + + const actor = resolveContentActor(c); + const result = await withHttpErrors( + "update", + async () => + await schedulesModel(c).schedule({ + action, + actorUserId: actor.userId, + itemId: id, + scheduledFor: new Date(scheduledFor), + }), + { contentTypeId: definition.id, itemId: id, structured: true }, + ); + + // Scheduling changes no field value, so it writes no revision and burns + // no version - but it is still something other plugins may want to react + // to, so it gets an event of its own. + await emitContentEvent( + c, + definition, + "scheduled", + { + action, + actorUserId: actor.userId, + contentId: id, + scheduledFor: result.scheduledFor, + scheduleId: result.id, + } as never, + { pluginId }, + ); + + return c.json(result, 200); + }, + }); + + const scheduleCancel = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.publish }, + route: { + method: "post", + path: "/{id}/schedule/{scheduleId}/cancel", + description: `Cancel a pending schedule for one ${label.singular}`, + request: { + params: z.object({ + id: z.coerce.number(), + scheduleId: z.coerce.number(), + }), + }, + responses: { + 200: jsonResponse(z.object({ cancelled: z.boolean() }), "Cancelled"), + 400: invalidIdentifier, + 404: { description: "Schedule not found" }, + }, + }, + handler: async c => { + const id = identifier(c); + const scheduleId = Number(c.req.param("scheduleId")); + if (!Number.isInteger(scheduleId) || scheduleId <= 0) { + throw new HTTPException(400, { + message: "Invalid schedule identifier.", + }); + } + + // Scoped by the record in the URL as well as the schedule id: the table + // is shared, so an id alone proves nothing about ownership. + const cancelled = await schedulesModel(c).cancel(id, scheduleId); + if (!cancelled) { + throw new HTTPException(404, { message: "Schedule not found." }); + } + + const actor = resolveContentActor(c); + await emitContentEvent( + c, + definition, + "schedule_cancelled", + { + action: cancelled.action, + actorUserId: actor.userId, + contentId: id, + scheduleId, + } as never, + { pluginId }, + ); + + // The queued task is deliberately left alone. It will wake up, find the + // row cancelled, and do nothing - which is far more reliable than trying + // to hunt down and delete a queue row. + return c.json({ cancelled: true }, 200); + }, + }); + + /** The precondition an editorial delete carries. */ + const deleteEnvelope = z.strictObject({ + expectedVersion: z.number().int().positive(), + }); + + /** + * The editorial `DELETE`: same path and method, one required body key. + * + * A body on a `DELETE` is unusual, and it is still the right shape here: the + * precondition belongs with the request that acts on it, and the alternative + * - a query parameter - puts a value that must not be guessed into access + * logs and browser history. + * + * Required rather than optional. Deleting is the widest overwrite there is, + * and a confirmation dialog that names a record cannot describe a change the + * person has not seen. + */ + const editorialRemove = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, + route: { + method: "delete", + path: "/{id}", + description: `Delete a ${label.singular}`, + request: { params: schemas.params, body: jsonBody(deleteEnvelope) }, + responses: { + 200: jsonResponse( + schemas.selectObject, + `${label.singular} deleted successfully`, + ), + 400: invalidIdentifier, + 404: { description: `${label.singular} not found` }, + 409: jsonResponse( + zodContentConflict, + "Still referenced by other content, or the version moved", + ), + }, + }, + handler: async c => { + const id = identifier(c); + const { expectedVersion } = await readJson(c, deleteEnvelope); + + // The history outlives the record: a final `delete` revision is what makes + // "who removed this, and what did it say" answerable afterwards. + const result = await withHttpErrors( + "delete", + async () => + await editorialService(c).delete(id, { + actor: resolveContentActor(c), + expectedVersion, + }), + { contentTypeId: definition.id, itemId: id, structured: true }, + ); + if (!result) throw notFound(definition); + + await contentEditorialEffects(c, definition, result, { pluginId }); + + return c.json(result.row, 200); + }, + }); + const remove = buildRoute({ pluginId, adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, @@ -378,12 +1109,20 @@ export const buildContentRoutes = < }, }, handler: async c => { + const id = identifier(c); + const row = await withHttpErrors("delete", async () => - model.service(c).delete(identifier(c)), + model.service(c).delete(id), ); if (!row) throw notFound(definition); - await emitContentEvent(c, definition, "deleted", { contentId: row.id }); + await emitContentEvent( + c, + definition, + "deleted", + { contentId: row.id }, + { pluginId }, + ); // `publishedAt` survives an unpublish, so a record that was ever published // is removed from the index defensively - a delete of a document that is @@ -403,10 +1142,17 @@ export const buildContentRoutes = < options, detail, create, - update, - remove, + // Same method and path either way; only the body shape differs, so exactly + // one of the two is ever mounted. + editorial ? editorialUpdate : update, + editorial ? editorialRemove : remove, ...(definition.publication.enabled ? [publicationRoute("publish"), publicationRoute("unpublish")] : []), + ...(editorial ? [revisionList, revisionDetail, restore] : []), + ...(previewEnabled ? [previewToken] : []), + ...(definition.editorial.scheduling.enabled + ? [scheduleList, scheduleCreate, scheduleCancel] + : []), ]; }; diff --git a/packages/vitnode/src/content/server/schedule-effects.test.ts b/packages/vitnode/src/content/server/schedule-effects.test.ts new file mode 100644 index 000000000..aeac23b82 --- /dev/null +++ b/packages/vitnode/src/content/server/schedule-effects.test.ts @@ -0,0 +1,481 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testEditorialPostContentType } from "@/tests/content-fixtures"; + +import type { ContentScheduleEffectsPayload } from "./schedule-effects"; + +const contentEditorialEffects = vi.fn(); +const dispatchContentRevalidation = vi.fn(); +const recordContentScheduleEffectsError = vi.fn(); + +vi.mock("./editorial-effects", () => ({ + contentEditorialEffects: (...args: unknown[]) => + contentEditorialEffects(...args), +})); +vi.mock("./revalidate-bridge", () => ({ + dispatchContentRevalidation: (...args: unknown[]) => + dispatchContentRevalidation(...args), +})); +vi.mock("./schedules-model", () => ({ + recordContentScheduleEffectsError: (...args: unknown[]) => + recordContentScheduleEffectsError(...args), +})); + +const { contentScheduleEffectsPayloadSchema, runContentScheduleEffects } = + await import("./schedule-effects"); + +const PLUGIN_ID = "@vitnode/example"; + +const payload = ( + overrides: Partial = {}, +): ContentScheduleEffectsPayload => ({ + changedFields: [], + contentTypeId: testEditorialPostContentType.id, + itemId: 7, + operation: "publish", + pluginId: PLUGIN_ID, + previousSlug: "hello-world", + revisionId: 90, + row: { + createdAt: "2026-08-01T09:00:00.000Z", + id: 7, + publishedAt: "2026-08-05T12:00:00.000Z", + slug: "hello-world", + status: "published", + title: "Hello world", + updatedAt: "2026-08-05T12:00:00.000Z", + version: 4, + }, + scheduleId: 55, + scheduledBy: 3, + version: 4, + wasPublic: false, + ...overrides, +}); + +const harness = ({ registered = true }: { registered?: boolean } = {}) => { + const c = { + get: (key: string) => + key === "core" + ? { + contentModels: registered + ? [ + { + model: { definition: testEditorialPostContentType }, + pluginId: PLUGIN_ID, + }, + ] + : [], + } + : key === "db" + ? { db: true } + : undefined, + } as unknown as Context; + + return { c }; +}; + +/** What `EventsModel.emit` reports when every listener ran. */ +const eventDelivered = { + delivered: 2, + eventId: "event-1", + failures: [], + status: "delivered", +}; + +const eventFailed = { + delivered: 0, + eventId: "event-1", + failures: [ + { + error: "Service unavailable", + listener: "send-notification", + module: "notifications", + pluginId: PLUGIN_ID, + }, + ], + status: "delivered", +}; + +beforeEach(() => { + vi.clearAllMocks(); + contentEditorialEffects.mockResolvedValue({ + event: eventDelivered, + search: null, + }); + dispatchContentRevalidation.mockResolvedValue({ attempted: 1, delivered: 1 }); + recordContentScheduleEffectsError.mockResolvedValue(undefined); +}); + +describe("runContentScheduleEffects", () => { + it("emits, indexes and expires the cache exactly once", async () => { + const { c } = harness(); + + const outcome = await runContentScheduleEffects(c, payload()); + + expect(outcome.status).toBe("delivered"); + expect(contentEditorialEffects).toHaveBeenCalledTimes(1); + expect(dispatchContentRevalidation).toHaveBeenCalledTimes(1); + }); + + it("names the person who booked it, not the system that ran it", async () => { + const { c } = harness(); + + await runContentScheduleEffects(c, payload()); + + expect(contentEditorialEffects.mock.calls[0][3]).toEqual({ + pluginId: PLUGIN_ID, + scheduledBy: 3, + scheduleId: 55, + }); + }); + + it("credits the content type's plugin, not the core queue handler", async () => { + // Core owns `content-schedule-effects`, so `c.get("plugin")` says + // `@vitnode/core` while this runs. The event still belongs to whoever owns + // the content type, and the owner has to be passed explicitly to say so. + const { c } = harness(); + + await runContentScheduleEffects(c, payload()); + + expect(contentEditorialEffects.mock.calls[0][3]).toMatchObject({ + pluginId: PLUGIN_ID, + }); + }); + + it("carries the schedule id, so a listener can be idempotent about retries", async () => { + const { c } = harness(); + + await runContentScheduleEffects(c, payload()); + + expect(contentEditorialEffects.mock.calls[0][3]).toMatchObject({ + scheduleId: 55, + }); + }); + + it("never republishes - it only announces", async () => { + // The reason this is a separate task at all. Nothing here calls the + // editorial service, so a retry cannot move the record again. + const { c } = harness(); + + await runContentScheduleEffects(c, payload()); + + const outcome = contentEditorialEffects.mock.calls[0][2] as { + changed: boolean; + operation: string; + }; + expect(outcome).toMatchObject({ changed: true, operation: "publish" }); + }); + + it("turns the payload's ISO strings back into dates", async () => { + // `published` carries `publishedAt: Date`, and a listener must not be able + // to tell a scheduled publish from a clicked one. + const { c } = harness(); + + await runContentScheduleEffects(c, payload()); + + const { row } = contentEditorialEffects.mock.calls[0][2] as { + row: Record; + }; + expect(row.publishedAt).toBeInstanceOf(Date); + expect(row.createdAt).toBeInstanceOf(Date); + }); + + it("expires the old slug and the new one, without repeating either", async () => { + const { c } = harness(); + + await runContentScheduleEffects(c, payload()); + + expect(dispatchContentRevalidation.mock.calls[0][1]).toMatchObject({ + isPublic: true, + mode: "immediate", + slugs: ["hello-world"], + wasPublic: false, + }); + }); + + it("expires both when a transition moved the URL", async () => { + const { c } = harness(); + + await runContentScheduleEffects(c, payload({ previousSlug: "old-slug" })); + + expect(dispatchContentRevalidation.mock.calls[0][1]).toMatchObject({ + slugs: ["old-slug", "hello-world"], + }); + }); + + describe("retrying", () => { + it("throws when no web origin accepted the invalidation", async () => { + // The failure this task exists for: a scheduled unpublish whose cache + // expiry did not land must be retried, and retrying the *publish* would + // skip the expiry entirely. + const { c } = harness(); + dispatchContentRevalidation.mockResolvedValue({ + attempted: 1, + delivered: 0, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow( + /cache/, + ); + }); + + it("throws when the search engine refused the document", async () => { + const { c } = harness(); + contentEditorialEffects.mockResolvedValue({ + event: eventDelivered, + search: { action: "upsert", documentId: "x", error: new Error("down") }, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow( + /search/, + ); + }); + + it("still expires the cache when search failed", async () => { + // Two independent systems. One being down is not a reason to skip the + // other, and both are retried together afterwards. + const { c } = harness(); + contentEditorialEffects.mockResolvedValue({ + event: eventDelivered, + search: { action: "upsert", documentId: "x", error: new Error("down") }, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow(); + + expect(dispatchContentRevalidation).toHaveBeenCalledTimes(1); + }); + + it("does not treat 'nothing to tell' as an outage", async () => { + // No tags to expire, or no web origin configured. Both are decisions. + const { c } = harness(); + dispatchContentRevalidation.mockResolvedValue({ + attempted: 0, + delivered: 0, + }); + + await expect( + runContentScheduleEffects(c, payload()), + ).resolves.toMatchObject({ status: "delivered" }); + }); + }); + + describe("multi-origin cache delivery", () => { + it("retries when one of two origins refused it", async () => { + // The dangerous case, and the one that used to pass. Two web apps behind + // one API: if only one expired its cache after a scheduled unpublish, the + // other keeps serving the withdrawn page - and calling that "delivered" + // means it never gets another chance to. + const { c } = harness(); + dispatchContentRevalidation.mockResolvedValue({ + attempted: 2, + delivered: 1, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow( + /1\/2 web origins/, + ); + }); + + it("records which origins are still stale", async () => { + const { c } = harness(); + dispatchContentRevalidation.mockResolvedValue({ + attempted: 3, + delivered: 2, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow(); + + expect(recordContentScheduleEffectsError).toHaveBeenCalledWith( + expect.anything(), + 55, + expect.stringContaining("cache: 2/3 web origins"), + ); + }); + + it("succeeds when every origin accepted it", async () => { + const { c } = harness(); + dispatchContentRevalidation.mockResolvedValue({ + attempted: 2, + delivered: 2, + }); + + await expect( + runContentScheduleEffects(c, payload()), + ).resolves.toMatchObject({ status: "delivered" }); + }); + }); + + describe("event delivery", () => { + it("retries when a listener failed", async () => { + // `EventsModel.emit` reports rather than throws, so a failure is only + // visible in the result. Discarding it made a dead notification listener + // indistinguishable from a delivered one. + const { c } = harness(); + contentEditorialEffects.mockResolvedValue({ + event: eventFailed, + search: null, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow( + /event/, + ); + }); + + it("keeps enough detail to find the listener that broke", async () => { + const { c } = harness(); + contentEditorialEffects.mockResolvedValue({ + event: eventFailed, + search: null, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow(); + + const [, , message] = recordContentScheduleEffectsError.mock.calls[0] as [ + unknown, + number, + string, + ]; + expect(message).toContain("notifications"); + expect(message).toContain("send-notification"); + expect(message).toContain("Service unavailable"); + }); + + it("still expires the cache when the event failed", async () => { + const { c } = harness(); + contentEditorialEffects.mockResolvedValue({ + event: eventFailed, + search: null, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow(); + + expect(dispatchContentRevalidation).toHaveBeenCalledTimes(1); + }); + + it("treats an event with no listeners as delivered", async () => { + // Nobody subscribed is not a failure. `delivered: 0` with no failures is + // the ordinary shape for an event nothing listens to. + const { c } = harness(); + contentEditorialEffects.mockResolvedValue({ + event: { ...eventDelivered, delivered: 0 }, + search: null, + }); + + await expect( + runContentScheduleEffects(c, payload()), + ).resolves.toMatchObject({ status: "delivered" }); + }); + + it("combines every outstanding failure into one message", async () => { + const { c } = harness(); + contentEditorialEffects.mockResolvedValue({ + event: eventFailed, + search: { + action: "upsert", + documentId: "x", + error: new Error("Elasticsearch unavailable"), + }, + }); + dispatchContentRevalidation.mockResolvedValue({ + attempted: 2, + delivered: 1, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow(); + + const [, , message] = recordContentScheduleEffectsError.mock.calls[0] as [ + unknown, + number, + string, + ]; + expect(message).toContain("event:"); + expect(message).toContain("search: Elasticsearch unavailable"); + expect(message).toContain("cache: 1/2 web origins"); + }); + + it("clears everything once one run gets all three through", async () => { + const { c } = harness(); + + await expect( + runContentScheduleEffects(c, payload()), + ).resolves.toMatchObject({ status: "delivered" }); + + expect(recordContentScheduleEffectsError).toHaveBeenCalledWith( + expect.anything(), + 55, + null, + ); + }); + }); + + describe("effect failure is reported separately", () => { + it("records why, without touching the schedule's status", async () => { + const { c } = harness(); + dispatchContentRevalidation.mockResolvedValue({ + attempted: 2, + delivered: 0, + }); + + await expect(runContentScheduleEffects(c, payload())).rejects.toThrow(); + + expect(recordContentScheduleEffectsError).toHaveBeenCalledWith( + expect.anything(), + 55, + expect.stringContaining("cache"), + ); + }); + + it("clears it on the run that finally gets through", async () => { + const { c } = harness(); + + await runContentScheduleEffects(c, payload()); + + expect(recordContentScheduleEffectsError).toHaveBeenCalledWith( + expect.anything(), + 55, + null, + ); + }); + }); + + it("gives up quietly when the content type has been removed", async () => { + // No definition means no event to build and no document to write. Retrying + // would never succeed, and the record is already correctly published. + const { c } = harness({ registered: false }); + + const outcome = await runContentScheduleEffects(c, payload()); + + expect(outcome.status).toBe("unregistered"); + expect(contentEditorialEffects).not.toHaveBeenCalled(); + expect(dispatchContentRevalidation).not.toHaveBeenCalled(); + }); +}); + +describe("contentScheduleEffectsPayloadSchema", () => { + it("accepts what the executor writes", () => { + expect( + contentScheduleEffectsPayloadSchema.safeParse(payload()).success, + ).toBe(true); + }); + + it("refuses a payload missing the record it is about", () => { + const { itemId, ...rest } = payload(); + void itemId; + + expect(contentScheduleEffectsPayloadSchema.safeParse(rest).success).toBe( + false, + ); + }); + + it("refuses an operation that is not a publication transition", () => { + expect( + contentScheduleEffectsPayloadSchema.safeParse( + payload({ operation: "update" as never }), + ).success, + ).toBe(false); + }); +}); diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts new file mode 100644 index 000000000..3a16bdf93 --- /dev/null +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -0,0 +1,233 @@ +import type { Context } from "hono"; + +import { z } from "zod"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentEditorialOutcome } from "./editorial-service"; + +import { CONTENT_SCHEDULE_ACTIONS } from "../const"; +import { contentEditorialEffects } from "./editorial-effects"; +import { findContentModel } from "./model"; +import { dispatchContentRevalidation } from "./revalidate-bridge"; +import { recordContentScheduleEffectsError } from "./schedules-model"; +import { isContentRowPublic } from "./search-document"; + +/** + * Everything the announcements need, and nothing they have to re-read. + * + * Written when the transition commits and never consulted against the live + * record afterwards. That is the point: by the time this runs the record may + * have been edited again, and an event describing *that* state would be a + * second, wrong announcement of a publication that already happened. + */ +export const contentScheduleEffectsPayloadSchema = z.object({ + changedFields: z.array(z.string()), + contentTypeId: z.string().min(1), + itemId: z.number().int().positive(), + operation: z.enum(CONTENT_SCHEDULE_ACTIONS), + pluginId: z.string().min(1), + previousSlug: z.string().nullable(), + /** `null` only if the transition somehow wrote no revision. */ + revisionId: z.number().int().positive().nullable(), + /** The row as the transition returned it, JSON-flattened. */ + row: z.record(z.string(), z.unknown()), + scheduleId: z.number().int().positive(), + scheduledBy: z.number().int().nullable(), + version: z.number().int().positive(), + wasPublic: z.boolean(), +}); + +export type ContentScheduleEffectsPayload = z.infer< + typeof contentScheduleEffectsPayloadSchema +>; + +/** + * Turns the ISO strings a JSON payload carries back into `Date`s. + * + * The search document already accepts either, but the `published` event payload + * is typed `publishedAt: Date` - and a listener that reads it should not be able + * to tell whether the publish was clicked or scheduled. + */ +const reviveDates = ( + definition: AnyContentTypeDefinition, + row: Record, +): Record => { + const dateColumns = [ + "createdAt", + "updatedAt", + ...(definition.publication.enabled ? ["publishedAt"] : []), + ...Object.entries(definition.fields) + .filter(([, field]) => field.kind === "dateTime") + .map(([name]) => name), + ]; + + const revived = { ...row }; + for (const name of dateColumns) { + const value = revived[name]; + if (typeof value !== "string") continue; + + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) revived[name] = parsed; + } + + return revived; +}; + +export interface ContentScheduleEffectsOutcome { + /** Why this run failed, when it did. Also written to the schedule row. */ + error?: string; + status: "delivered" | "unregistered"; +} + +/** + * Delivers the announcements a committed scheduled transition owes everyone + * else: its event, its search document, and its cache invalidation. + * + * **Split from the transition on purpose.** Publishing is a database write that + * either committed or did not. Telling the world is three calls to systems a + * transaction cannot reach, any of which can be down for a minute. Retrying + * them together would re-run the publish - which is idempotent, so the second + * run would find nothing changed and skip the announcements entirely. That is + * exactly how a scheduled unpublish ends up permanently serving a cached page it + * should have expired, and it is the failure this task exists to remove. + * + * **All three have to land.** A failed event, a refused search write and a web + * origin that did not accept its invalidation are each enough to fail the run, + * and the reasons are combined into one `effectsError` so the AdminCP shows + * everything outstanding rather than whichever failed first. + * + * **Delivery is at-least-once.** A retry after a partial failure re-emits the + * event and re-writes the search document. Both of the latter are idempotent by + * construction - a search upsert and a cache expiry are the same operation + * however many times they run - but an event listener may see the same + * `published` twice, so a listener that must act once keys off the + * `scheduleId` the payload carries. There is no outbox and no exactly-once + * claim. + */ +export const runContentScheduleEffects = async ( + c: Context, + payload: ContentScheduleEffectsPayload, +): Promise => { + const entry = findContentModel( + c.get("core").contentModels, + payload.contentTypeId, + ); + + // The plugin went away between the publish and this run. There is nothing + // left to announce and no definition to announce it with, so this is a dead + // end rather than a failure - throwing would retry it until the queue gives + // up, and the record is already correctly published either way. + if (!entry) { + await recordContentScheduleEffectsError( + c.get("db"), + payload.scheduleId, + `Content type "${payload.contentTypeId}" is no longer registered, so its scheduled ${payload.operation} was never announced.`, + ); + + return { status: "unregistered" }; + } + + const { definition } = entry.model; + const row = reviveDates(definition, payload.row); + + const outcome: ContentEditorialOutcome = { + changed: true, + changedFields: payload.changedFields, + operation: payload.operation, + previousSlug: payload.previousSlug, + restoredFromRevisionId: null, + revisionId: payload.revisionId, + row: row as never, + version: payload.version, + }; + + // The same helper the interactive routes use, so a scheduled publish and a + // clicked one are indistinguishable to every listener and to the index. + const { event, search } = await contentEditorialEffects( + c, + definition, + outcome, + { + // The content type's owner, not core - core only owns the queue handler + // that happens to be running. `entry.pluginId` is the same value the + // executor froze into the payload, and both are read back rather than + // taken from `c.get("plugin")`, which says `@vitnode/core` here. + pluginId: payload.pluginId, + scheduledBy: payload.scheduledBy, + scheduleId: payload.scheduleId, + }, + ); + + const currentSlug = definition.publicApi.enabled + ? row[definition.publicApi.slugField] + : undefined; + + const revalidation = await dispatchContentRevalidation(c, { + contentTypeId: definition.id, + id: payload.itemId, + isPublic: isContentRowPublic(row), + mode: "immediate", + // Both, because a transition that moved the URL has to expire the one it + // used to answer to as well. + slugs: [ + ...new Set( + [payload.previousSlug, currentSlug].filter( + (slug): slug is string => typeof slug === "string" && slug !== "", + ), + ), + ], + wasPublic: payload.wasPublic, + }); + + const failures: string[] = []; + + // `EventsModel.emit` never throws, so this is the only place a dead listener + // or a broker outage is visible. Ignoring it would mean an announcement that + // nobody received counts as delivered and is never retried. + if (event && event.failures.length > 0) { + failures.push( + `event: ${event.failures + .map( + failure => + `${failure.pluginId}:${failure.module}:${failure.listener} (${failure.error})`, + ) + .join(", ")}`, + ); + } + + if (search?.error) failures.push(`search: ${search.error.message}`); + + // Every configured origin has to accept it. A partial delivery is the + // dangerous case, not the acceptable one: with two web apps behind one API, + // one of them accepting an unpublish while the other does not leaves the + // withdrawn page cached and readable, and "at least one worked" would call + // that a success and never try the other again. + // + // `attempted: 0` is different - it means there was nothing to tell, because + // no tag needed expiring or no web origin is configured. That is a decision + // somebody made, not an outage. + if ( + revalidation.attempted > 0 && + revalidation.delivered < revalidation.attempted + ) { + failures.push( + `cache: ${revalidation.delivered}/${revalidation.attempted} web origins accepted the invalidation`, + ); + } + + const error = failures.length > 0 ? failures.join("; ") : null; + await recordContentScheduleEffectsError( + c.get("db"), + payload.scheduleId, + error, + ); + + if (error) { + // Thrown so the queue's own backoff retries *this* - never the publish. + throw new Error( + `Scheduled ${payload.operation} of ${payload.contentTypeId}#${payload.itemId} committed, but its effects did not (${error}).`, + ); + } + + return { status: "delivered" }; +}; diff --git a/packages/vitnode/src/content/server/schedules-model.ts b/packages/vitnode/src/content/server/schedules-model.ts new file mode 100644 index 000000000..5a94b7385 --- /dev/null +++ b/packages/vitnode/src/content/server/schedules-model.ts @@ -0,0 +1,409 @@ +import type { Context } from "hono"; + +import { and, desc, eq, inArray, lt, notInArray, sql } from "drizzle-orm"; + +import type { + ContentSchedule, + ContentScheduleAction, + ContentScheduleStatus, +} from "../schedules"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; + +import { core_content_schedules } from "../../database/content"; +import { core_users } from "../../database/users"; +import { CONTENT_QUEUE_TASK_SCHEDULE, CONTENT_SCHEDULE_CODES } from "../const"; +import { ContentScheduleError } from "../errors"; +import { contentScheduleTimingError } from "../schedules"; + +/** A schedule row claimed for execution, with everything the handler needs. */ +export interface ClaimedContentSchedule { + action: ContentScheduleAction; + contentTypeId: string; + createdBy: null | number; + id: number; + itemId: number; + pluginId: string; +} + +/** + * Locks one schedule row and returns it only if it is still worth running. + * + * Four conditions, every one of them re-read from the database under + * `FOR UPDATE` rather than trusted from the queue payload: the row exists, it + * is still `pending`, its generation matches the one the task was dispatched + * with, and its time has come. Anything else returns `null`, and the task does + * nothing at all - which is exactly how a task left over from a cancelled or + * rescheduled plan stays harmless. + * + * Keyed by id alone, unlike every other query in this file, and that is safe + * for the one reason the others are not: it does not *trust* a scope, it + * **returns** one. The caller learns which plugin and content type the row + * belongs to from the row itself, under the lock. + */ +export const claimContentSchedule = async ( + tx: ContentDatabase, + { + generation, + now = new Date(), + scheduleId, + }: { + generation: number; + now?: Date; + scheduleId: number; + }, +): Promise => { + const [row] = await tx + .select({ + action: core_content_schedules.action, + contentTypeId: core_content_schedules.contentTypeId, + createdBy: core_content_schedules.createdBy, + generation: core_content_schedules.generation, + id: core_content_schedules.id, + itemId: core_content_schedules.itemId, + pluginId: core_content_schedules.pluginId, + scheduledFor: core_content_schedules.scheduledFor, + status: core_content_schedules.status, + }) + .from(core_content_schedules) + .where(eq(core_content_schedules.id, scheduleId)) + .limit(1) + .for("update"); + + if (!row) return null; + if (row.status !== "pending") return null; + if (row.generation !== generation) return null; + if (row.scheduledFor.getTime() > now.getTime()) return null; + + return { + action: row.action, + contentTypeId: row.contentTypeId, + createdBy: row.createdBy, + id: row.id, + itemId: row.itemId, + pluginId: row.pluginId, + }; +}; + +/** + * Records how a claimed schedule ended. + * + * Id-keyed like {@link claimContentSchedule}, and guarded by `expectedStatus` + * for a reason that is easy to miss: `cancelled` and `completed` are both + * terminal, so an unguarded write would let a stale worker turn a schedule an + * administrator cancelled into one that ran. The guard is `AND status = $x` in + * the same statement rather than a read followed by a write, so there is no + * window between checking and setting. + * + * Returns whether the row was in the expected state. `false` is a concurrency + * signal, never something to shrug at - the caller decides whether that means + * "somebody got there first, fine" or "this cannot happen, roll back". + */ +export const settleContentSchedule = async ( + db: ContentDatabase, + scheduleId: number, + patch: { + /** Only write when the row still holds this status. */ + expectedStatus?: ContentScheduleStatus; + lastError?: null | string; + status?: "cancelled" | "completed"; + }, +): Promise => { + const rows = await db + .update(core_content_schedules) + .set({ + ...(patch.status ? { status: patch.status } : {}), + ...(patch.status === "completed" ? { completedAt: new Date() } : {}), + ...(patch.lastError === undefined ? {} : { lastError: patch.lastError }), + }) + .where( + patch.expectedStatus === undefined + ? eq(core_content_schedules.id, scheduleId) + : and( + eq(core_content_schedules.id, scheduleId), + eq(core_content_schedules.status, patch.expectedStatus), + ), + ) + .returning({ id: core_content_schedules.id }); + + return rows.length > 0; +}; + +/** + * Records why a schedule's post-commit effects have not been delivered yet. + * + * Deliberately **not** a status change. The publication itself succeeded and + * must stay `completed`; what failed is the announcement, and moving the row + * back to `pending` would republish something that is already live. Cleared on + * the retry that finally gets through. + */ +export const recordContentScheduleEffectsError = async ( + db: ContentDatabase, + scheduleId: number, + effectsError: null | string, +): Promise => { + await db + .update(core_content_schedules) + .set({ effectsError }) + .where(eq(core_content_schedules.id, scheduleId)); +}; + +export interface ContentSchedulesModel { + /** + * Marks a pending schedule cancelled, and says which one it was. + * + * `null` when there was no pending schedule with that id on that record - + * which the route turns into a 404 rather than a silent success, because + * "cancelled" and "there was nothing to cancel" are different answers. + */ + cancel: ( + itemId: number, + scheduleId: number, + ) => Promise; + /** Pending and recent schedules for one record, newest first. */ + listForItem: (itemId: number) => Promise; + /** Pending rows only, for the ordering rule. */ + pendingForItem: ( + itemId: number, + tx?: ContentDatabase, + ) => Promise<{ action: ContentScheduleAction; scheduledFor: Date }[]>; + recordError: (scheduleId: number, message: string) => Promise; + /** + * Cancels any pending schedule for this `(item, action)` and inserts a new + * one, in one transaction with its queue row. + */ + schedule: (input: { + action: ContentScheduleAction; + actorUserId: null | number; + itemId: number; + now?: Date; + scheduledFor: Date; + }) => Promise<{ generation: number; id: number; scheduledFor: Date }>; +} + +/** How many past schedules the AdminCP panel shows alongside the pending ones. */ +const HISTORY_LIMIT = 10; + +/** + * Schedule reads and writes for one content type. + * + * Like the revisions model, **every** statement filters on `pluginId`, + * `contentTypeId` *and* `itemId`. The table is shared by every schedulable + * content type in the install, so a schedule id on its own proves nothing - and + * cancelling somebody else's publication would be a strange way to find that + * out. + */ +export const createContentSchedulesModel = ({ + c, + definition, + pluginId, +}: { + c: Context; + definition: AnyContentTypeDefinition; + pluginId: string; +}): ContentSchedulesModel => { + const contentTypeId = definition.id; + + const scope = (itemId: number) => + and( + eq(core_content_schedules.pluginId, pluginId), + eq(core_content_schedules.contentTypeId, contentTypeId), + eq(core_content_schedules.itemId, itemId), + ); + + const pendingForItem = async (itemId: number, tx?: ContentDatabase) => + await (tx ?? c.get("db")) + .select({ + action: core_content_schedules.action, + scheduledFor: core_content_schedules.scheduledFor, + }) + .from(core_content_schedules) + .where(and(scope(itemId), eq(core_content_schedules.status, "pending"))); + + return { + cancel: async (itemId, scheduleId) => { + const [row] = await c + .get("db") + .update(core_content_schedules) + .set({ status: "cancelled" }) + .where( + and( + scope(itemId), + eq(core_content_schedules.id, scheduleId), + // Only a pending one can be cancelled. Re-cancelling a completed + // schedule would rewrite history to say it never ran. + eq(core_content_schedules.status, "pending"), + ), + ) + .returning({ action: core_content_schedules.action }); + + return row ?? null; + }, + + listForItem: async itemId => { + const rows = await c + .get("db") + .select({ + action: core_content_schedules.action, + actorName: core_users.name, + completedAt: core_content_schedules.completedAt, + createdAt: core_content_schedules.createdAt, + createdBy: core_content_schedules.createdBy, + effectsError: core_content_schedules.effectsError, + id: core_content_schedules.id, + lastError: core_content_schedules.lastError, + scheduledFor: core_content_schedules.scheduledFor, + status: core_content_schedules.status, + }) + .from(core_content_schedules) + .leftJoin( + core_users, + eq(core_users.id, core_content_schedules.createdBy), + ) + .where(scope(itemId)) + // Pending first whatever their date, then the rest newest-first: the + // panel is answering "what is going to happen" before "what happened". + .orderBy( + sql`case when ${core_content_schedules.status} = 'pending' then 0 else 1 end`, + desc(core_content_schedules.scheduledFor), + ) + .limit(HISTORY_LIMIT); + + return rows; + }, + + pendingForItem, + + schedule: async ({ + action, + actorUserId, + itemId, + now = new Date(), + scheduledFor, + }) => { + // Belt and braces: the route only exists for a schedulable content type, + // so this can only fire on a direct call - which is exactly when a clear + // error beats a confusing one. + if (!definition.editorial.scheduling.enabled) { + throw new ContentScheduleError( + `This content type has no scheduling, so there is no "${action}" to schedule.`, + { code: CONTENT_SCHEDULE_CODES.unsupported, contentTypeId }, + ); + } + + return await c.get("db").transaction(async tx => { + const pending = await pendingForItem(itemId, tx); + + const timing = contentScheduleTimingError({ + action, + now, + // The row about to be replaced is not a constraint on its replacement. + pending: pending.filter(entry => entry.action !== action), + scheduledFor, + }); + if (timing) { + throw new ContentScheduleError( + timing === CONTENT_SCHEDULE_CODES.order + ? "An unpublish has to be scheduled after the publish that is already pending." + : "That time has already passed.", + { code: timing, contentTypeId }, + ); + } + + // Cancel-then-insert rather than update: the old row stays in the + // history as a cancelled plan, so "we moved it twice" is recoverable. + const [previous] = await tx + .update(core_content_schedules) + .set({ status: "cancelled" }) + .where( + and( + scope(itemId), + eq(core_content_schedules.action, action), + eq(core_content_schedules.status, "pending"), + ), + ) + .returning({ generation: core_content_schedules.generation }); + + const generation = (previous?.generation ?? 0) + 1; + + const [row] = await tx + .insert(core_content_schedules) + .values({ + action, + contentTypeId, + createdBy: actorUserId, + generation, + itemId, + pluginId, + scheduledFor, + }) + .returning({ id: core_content_schedules.id }); + + // Dispatched inside the transaction, so a schedule row can never exist + // without the task that executes it - and the task carries a pointer + // rather than data, because every value is re-read under a lock anyway. + await c.get("queue").dispatch({ + availableAt: scheduledFor, + name: CONTENT_QUEUE_TASK_SCHEDULE, + payload: { generation, scheduleId: row.id }, + // Core owns the handler. Without this the row would be stamped with + // the requesting plugin's id and nothing would ever claim it. + pluginId: "@vitnode/core", + tx, + }); + + return { generation, id: row.id, scheduledFor }; + }); + }, + + recordError: async (scheduleId, message) => { + await c + .get("db") + .update(core_content_schedules) + .set({ lastError: message }) + .where(eq(core_content_schedules.id, scheduleId)); + }, + }; +}; + +/** + * Removes schedules that no longer describe anything. + * + * Two sweeps, both keyed by data rather than by a registry lookup at write + * time: settled rows past the retention window, and rows for a content type + * that is no longer registered at all - a plugin removed, or `scheduling` + * turned off. + */ +export const pruneContentSchedules = async ({ + db, + knownContentTypeIds, + olderThan, +}: { + db: ContentDatabase; + knownContentTypeIds: string[]; + olderThan: Date; +}): Promise<{ orphaned: number; settled: number }> => { + const settled = await db + .delete(core_content_schedules) + .where( + and( + inArray(core_content_schedules.status, ["cancelled", "completed"]), + lt(core_content_schedules.updatedAt, olderThan), + ), + ) + .returning({ id: core_content_schedules.id }); + + // An empty list genuinely means "no content type schedules any more", so + // every remaining row is an orphan. `notInArray` with an empty array is not + // valid SQL, hence the branch rather than a clever one-liner. + const orphaned = await db + .delete(core_content_schedules) + .where( + knownContentTypeIds.length === 0 + ? sql`true` + : notInArray(core_content_schedules.contentTypeId, knownContentTypeIds), + ) + .returning({ id: core_content_schedules.id }); + + return { orphaned: orphaned.length, settled: settled.length }; +}; diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index 5324af904..77ed43468 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -10,7 +10,7 @@ import { contentSearchDocument, isContentRowPublic } from "./search-document"; /** Which mutation just returned. Not an event name: nothing is emitted here. */ export type ContentSearchOperation = - "create" | "delete" | "publish" | "unpublish" | "update"; + "create" | "delete" | "publish" | "restore" | "unpublish" | "update"; export interface ContentSearchSyncInput { /** @@ -19,7 +19,10 @@ export interface ContentSearchSyncInput { * to do. */ changed?: boolean; - /** `update` only. An update that touched no indexed field changes no document. */ + /** + * `update` and `restore` only. A write that touched no indexed field changes + * no document. + */ changedFields?: readonly string[]; operation: ContentSearchOperation; /** @@ -67,9 +70,12 @@ const decide = ( if (operation === "create") return isPublic ? "upsert" : "skip"; - // `update` cannot change `status`, so a draft stays a draft and a published - // record stays published. Nothing to delete, and nothing to write unless a - // field the document is built from actually moved. + // `update` and `restore` both write field values and neither can change + // `status` - a restore projects only declared fields, and the publication + // columns are not among them. So a draft stays a draft and a published record + // stays published: nothing to delete, and nothing to write unless a field the + // document is built from actually moved. A slug change is covered, because + // the exposed slug is one of the indexed field names. if (!isPublic) return "skip"; const indexed = new Set(contentSearchIndexedFieldNames(definition)); diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index dd491d5a7..7e1b15083 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -12,7 +12,6 @@ import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, ContentCreateInput, - ContentFieldMap, ContentFieldName, ContentFilterInput, ContentOrderableFieldName, @@ -24,13 +23,12 @@ import type { import { withPagination } from "../../api/lib/with-pagination"; import { CONTENT_DEFAULT_PAGE_SIZE, + CONTENT_EDITORIAL_FIELDS, CONTENT_OPTIONS_LIMIT, CONTENT_PUBLICATION_FIELDS, - CONTENT_SLUG_DEFAULT_LENGTH, } from "../const"; -import { ContentEngineError, ContentInputError } from "../errors"; +import { ContentEngineError } from "../errors"; import { orderableColumns } from "../registry"; -import { slugify } from "../slug"; import { buildFilterCondition, buildOrderColumn, @@ -39,6 +37,7 @@ import { toColumnValues, } from "./query"; import { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; +import { createSlugNormalizer } from "./slugs"; /** Display labels for `user` and `relation` values, keyed by field name. */ export type ContentLabels = Record; @@ -68,8 +67,15 @@ export interface ContentFindManyArgs { where?: SQL; } -/** The Drizzle client, or a transaction handle standing in for it. */ -export type ContentDatabase = Context["var"]["db"]; +/** + * The Drizzle client, or a transaction handle standing in for it. + * + * `$client` is omitted deliberately: a `PgTransaction` carries every query + * method the client does but not the raw driver handle, so naming the client + * type directly would make `db.transaction(async tx => service.update(id, v, + * { tx }))` - the whole point of the option - a type error. + */ +export type ContentDatabase = Omit; export interface ContentServiceOptions { /** Run inside an existing transaction. */ @@ -155,29 +161,6 @@ 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; -}; - /** * A typed repository bound to one request's database handle. * @@ -219,95 +202,17 @@ export const createContentService = < "createdAt", "updatedAt", ...(publication ? CONTENT_PUBLICATION_FIELDS : []), + ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), ...fieldNames, ]; const references = resolveReferenceTargets(definition, table, columns); const searchColumns = definition.admin.list.searchableFields.map( name => columns[name], ); - const slugFields = slugFieldsOf(fields); - - /** - * Normalises a slug and refuses one that folds to nothing. - * - * Nothing random or numeric is appended - `slugify` is deterministic, and - * uniqueness belongs to the unique index, which surfaces a clash as a 409. - */ - const toSlug = ( - slugField: SlugFieldConfig, - value: string, - derived: boolean, - ): string => { - const slug = slugify(value, slugField.maxLength); - if (slug !== "") return slug; - - throw new ContentInputError( - derived - ? `Could not derive "${slugField.name}" from "${slugField.source}". Send "${slugField.name}" explicitly.` - : `Field "${slugField.name}" normalises to an empty slug. Use at least one letter or digit.`, - { contentTypeId }, - ); - }; - - /** - * Fills in and normalises every slug on the way into a create. - * - * A supplied value is normalised rather than trusted, so the same rules apply - * whether the slug came from the caller or from the source field. - */ - const withCreateSlugs = ( - values: Record, - ): Record => { - if (slugFields.length === 0) return values; - - const next = { ...values }; - - for (const slugField of slugFields) { - const supplied = next[slugField.name]; - - if (typeof supplied === "string") { - next[slugField.name] = toSlug(slugField, supplied, false); - continue; - } - - // `assertSlugSources` guarantees a source exists whenever the create - // schema lets the value be omitted, so this is the derived branch. - const source = slugField.source ?? ""; - const from = next[source]; - - next[slugField.name] = toSlug( - slugField, - typeof from === "string" ? from : "", - true, - ); - } - - return next; - }; - - /** - * Normalises the slugs an update actually names, and only those. - * - * A slug is never re-derived here: editing the title of a published article - * must not silently move its URL and 404 every link to it. Sending the slug - * is the only way to change it. - */ - const withUpdateSlugs = ( - patch: Record, - ): Record => { - if (slugFields.length === 0) return patch; - - const next = { ...patch }; - - for (const slugField of slugFields) { - const supplied = next[slugField.name]; - if (typeof supplied !== "string") continue; - - next[slugField.name] = toSlug(slugField, supplied, false); - } - - return next; - }; + const { withCreateSlugs, withUpdateSlugs } = createSlugNormalizer( + contentTypeId, + fields, + ); const db = (options?: ContentServiceOptions): ContentDatabase => options?.tx ?? c.get("db"); diff --git a/packages/vitnode/src/content/server/slugs.ts b/packages/vitnode/src/content/server/slugs.ts new file mode 100644 index 000000000..3e5af63eb --- /dev/null +++ b/packages/vitnode/src/content/server/slugs.ts @@ -0,0 +1,121 @@ +import type { ContentFieldMap } from "../types"; + +import { CONTENT_SLUG_DEFAULT_LENGTH } from "../const"; +import { ContentInputError } from "../errors"; +import { slugify } from "../slug"; + +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; +}; + +export interface ContentSlugNormalizer { + /** Fills in and normalises every slug on the way into a create. */ + withCreateSlugs: (values: Record) => Record; + /** Normalises the slugs a patch actually names, and only those. */ + withUpdateSlugs: (patch: Record) => Record; +} + +/** + * The slug rules, in one place. + * + * Shared by the plain service and the editorial one rather than duplicated: a + * restore writes through the same normalisation an update does, and two copies + * of "never re-derive on update" is exactly the pair that drifts. + */ +export const createSlugNormalizer = ( + contentTypeId: string, + fields: ContentFieldMap, +): ContentSlugNormalizer => { + 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 }, + ); + }; + + return { + withCreateSlugs: values => { + if (slugFields.length === 0) return values; + + const next = { ...values }; + + for (const slugField of slugFields) { + const supplied = next[slugField.name]; + + // 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. + 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; + }, + + withUpdateSlugs: patch => { + if (slugFields.length === 0) return patch; + + const next = { ...patch }; + + // 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. + for (const slugField of slugFields) { + const supplied = next[slugField.name]; + if (typeof supplied !== "string") continue; + + next[slugField.name] = toSlug(slugField, supplied, false); + } + + return next; + }, + }; +}; diff --git a/packages/vitnode/src/content/server/table.test.ts b/packages/vitnode/src/content/server/table.test.ts index 6e4844a2b..63cae5e4a 100644 --- a/packages/vitnode/src/content/server/table.test.ts +++ b/packages/vitnode/src/content/server/table.test.ts @@ -75,6 +75,61 @@ describe("createContentTable", () => { }); }); + describe("editorial column", () => { + const editorialTable = createContentTable( + defineContentType({ + id: "test.versioned", + tableName: "test_versioned", + fields: { title: field.text({ required: true }) }, + editorial: { enabled: true }, + admin: { label: { plural: "Versioned", singular: "Versioned" } }, + }), + ); + const versionColumn = getTableConfig(editorialTable).columns.find( + item => item.name === "version", + ); + + it("adds `version` when editorial is enabled", () => { + expect(versionColumn?.getSQLType()).toBe("integer"); + expect(versionColumn?.notNull).toBe(true); + }); + + it("defaults it to 1, so drizzle-kit backfills in one statement", () => { + expect(versionColumn?.hasDefault).toBe(true); + expect(versionColumn?.default).toBe(1); + }); + + it("adds nothing when editorial is omitted", () => { + expect(column("version")).toBeUndefined(); + expect( + getTableConfig(categories).columns.find( + item => item.name === "version", + ), + ).toBeUndefined(); + }); + + it("leaves a content type's own `version` field alone", () => { + // Not generated, so it is an ordinary declared column with its own type. + const own = createContentTable( + defineContentType({ + id: "test.own-version-column", + tableName: "test_own_version_column", + fields: { + title: field.text({ required: true }), + version: field.text({ defaultValue: "v1" }), + }, + admin: { label: { plural: "Owns", singular: "Own" } }, + }), + ); + + expect( + getTableConfig(own) + .columns.find(item => item.name === "version") + ?.getSQLType(), + ).toBe("varchar(255)"); + }); + }); + describe("field columns", () => { it.each([ ["title", "varchar(200)", true], diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 4235c0d20..5681f093b 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -25,10 +25,11 @@ import type { } from "./types"; import { core_users } from "../../database/users"; -import { CONTENT_PUBLICATION_FIELDS } from "../const"; +import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const"; import { ContentEngineError } from "../errors"; import { buildContentColumn, + buildEditorialColumns, buildPublicationColumns, buildSystemColumns, } from "./column-builders"; @@ -147,6 +148,7 @@ export const createContentTable = < const columns: Record = { ...buildSystemColumns(), ...(definition.publication.enabled ? buildPublicationColumns() : {}), + ...(definition.editorial.enabled ? buildEditorialColumns() : {}), }; for (const name of Object.keys(fields)) { @@ -219,6 +221,7 @@ export const contentTableColumns = < "createdAt", "updatedAt", ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), + ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), ...Object.keys(definition.fields), ]; diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index fa5207bd3..6868b3d23 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -16,6 +16,7 @@ import type { } from "drizzle-orm/pg-core"; import type { + ContentEditorialField, ContentFieldsOf, ContentPublicationField, ContentSystemField, @@ -96,10 +97,22 @@ type PublicationColumnBuilders = ? ContentPublicationColumnBuilders : Record; +/** `version` - added only when the editorial workflow is enabled. */ +export interface ContentEditorialColumnBuilders { + version: NotNull>>; +} + +type EditorialColumnBuilders = + TEditorial extends true + ? ContentEditorialColumnBuilders + : Record; + export type ContentColumnBuilders< TFields, TPublication extends boolean = false, + TEditorial extends boolean = false, > = ContentSystemColumnBuilders & + EditorialColumnBuilders & PublicationColumnBuilders & { [K in keyof TFields]: ContentColumnBuilder; }; @@ -114,10 +127,11 @@ export type ContentTable< TName extends string, TFields, TPublication extends boolean = false, + TEditorial extends boolean = false, > = PgTableWithColumns<{ columns: BuildColumns< TName, - ContentColumnBuilders, + ContentColumnBuilders, "pg" >; dialect: "pg"; @@ -126,16 +140,20 @@ export type ContentTable< }>; export type ContentTableFor = TDefinition extends { + editorial: { enabled: infer TEditorial extends boolean }; publication: { enabled: infer TPublication extends boolean }; tableName: infer TName extends string; } - ? ContentTable, TPublication> + ? ContentTable, TPublication, TEditorial> : never; /** Column name -> Drizzle column, used for allowlisted filters and ordering. */ export type ContentColumnName = | ContentSystemField | (keyof ContentFieldsOf & string) + | (TDefinition extends { editorial: { enabled: true } } + ? ContentEditorialField + : never) | (TDefinition extends { publication: { enabled: true } } ? ContentPublicationField : never); diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index dfcc4e4ba..4343442ed 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,4 +1,5 @@ import type { + CONTENT_EDITORIAL_FIELDS, CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_PUBLIC_EXPOSABLE_COLUMNS, CONTENT_PUBLICATION_FIELDS, @@ -15,6 +16,8 @@ export type ContentSystemField = (typeof CONTENT_SYSTEM_FIELDS)[number]; export type ContentPublicationField = (typeof CONTENT_PUBLICATION_FIELDS)[number]; +export type ContentEditorialField = (typeof CONTENT_EDITORIAL_FIELDS)[number]; + export type ContentPublicationStatus = (typeof CONTENT_PUBLICATION_STATUSES)[number]; @@ -195,14 +198,19 @@ export type ContentFieldMap = Record; * * `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. + * declaring its own `status` enum. `TEditorial` does the same for `version`. */ -export type ContentFieldsConstraint = - Partial> & - Record & - (TPublication extends true - ? Partial> - : unknown); +export type ContentFieldsConstraint< + TPublication extends boolean = false, + TEditorial extends boolean = false, +> = Partial> & + Record & + (TEditorial extends true + ? Partial> + : unknown) & + (TPublication extends true + ? Partial> + : unknown); /** Fields that hold a foreign key to another row. */ export type ContentReferenceField = ContentRelationField | ContentUserField; @@ -286,17 +294,34 @@ export interface ContentAdminLabel { type ContentPublicationColumn = TPublication extends true ? ContentPublicationField : never; +/** The same rule for `version`, which only exists with `editorial`. */ +type ContentEditorialColumn = + TEditorial extends true ? ContentEditorialField : never; + +/** + * Every column name the admin config and `indexes` may address: the declared + * fields, the system columns, and whichever generated columns the content type + * opted into. + */ +type ContentAddressableColumn< + TFields, + TPublication extends boolean, + TEditorial extends boolean, +> = + | ContentEditorialColumn + | ContentPublicationColumn + | ContentSystemField + | keyof TFields; + export interface ContentAdminListConfig< TFields = ContentFieldMap, TPublication extends boolean = boolean, + TEditorial extends boolean = boolean, > { /** Columns shown in the DataTable, in order. Defaults to every field. */ - columns?: ( - ContentPublicationColumn | ContentSystemField | keyof TFields - )[]; + columns?: ContentAddressableColumn[]; defaultOrder?: "asc" | "desc"; - defaultOrderBy?: - ContentPublicationColumn | ContentSystemField | keyof TFields; + defaultOrderBy?: ContentAddressableColumn; /** * Allowlist for `orderBy`. System columns - and the publication columns when * enabled - are always allowed and need no entry here. @@ -309,10 +334,11 @@ export interface ContentAdminListConfig< export interface ContentAdminConfig< TFields = ContentFieldMap, TPublication extends boolean = boolean, + TEditorial 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. @@ -355,20 +381,22 @@ export interface ResolvedContentAdminConfig { export interface ContentIndexInput< TFields = ContentFieldMap, TPublication extends boolean = boolean, + TEditorial extends boolean = boolean, > { /** Defaults to `__idx`, or `_key` when unique. */ name?: string; on: [ - ContentIndexColumn, - ...ContentIndexColumn[], + ContentIndexColumn, + ...ContentIndexColumn[], ]; unique?: boolean; } -type ContentIndexColumn = - | ContentPublicationColumn - | ContentSystemField - | (keyof TFields & string); +type ContentIndexColumn< + TFields, + TPublication extends boolean, + TEditorial extends boolean, +> = ContentAddressableColumn & string; /** * Stored shape. Non-generic for the same reason as @@ -611,6 +639,122 @@ export interface ResolvedContentSearchConfig< titleField: string; } +// --------------------------------------------------------------------------- +// Editorial +// --------------------------------------------------------------------------- + +export interface ContentEditorialRevisionsConfig { + /** Newest revisions kept per record. 1-500, defaults to 50. */ + retention?: number; +} + +/** + * Opts into signed, expiring preview links for unpublished records. + * + * `enabled` is literal `true` for the same reason every other opt-in's is: a + * widened `boolean` would silently resolve to "no preview". + */ +export interface ContentEditorialPreviewConfig { + enabled: true; + /** How long a link stays valid. 1-1440 minutes, defaults to 15. */ + expiresInMinutes?: number; + /** + * Where the AdminCP sends a reviewer, e.g. `/articles/preview/{token}`. + * Relative, and `{token}` is the only placeholder. Omit it and the AdminCP + * links to the generated JSON endpoint instead. + */ + pathTemplate?: string; +} + +export interface ContentEditorialSchedulingConfig { + enabled: true; +} + +/** + * Opts a content type into the editorial workflow: a `version` column, + * optimistic locking and revision history. + * + * The two sub-features are gated on the capabilities they actually need, and + * the `{ enabled: false }` branches are what turn a mistake into a compile + * error rather than a boot-time one: + * + * - **preview** projects through `publicApi.fields`. Without a public allowlist + * there is nothing to project, so it needs `publicApi` (which already needs + * `publication`). + * - **scheduling** moves `status`, so it needs `publication`. It does *not* + * need a public API - a content type may run the lifecycle for the AdminCP + * badge alone. + */ +export interface ContentEditorialConfig< + TPublicEnabled extends boolean = boolean, + TPublication extends boolean = boolean, +> { + enabled: true; + preview?: TPublicEnabled extends true + ? ContentEditorialPreviewConfig | { enabled: false } + : { enabled: false }; + revisions?: ContentEditorialRevisionsConfig; + scheduling?: TPublication extends true + ? ContentEditorialSchedulingConfig | { enabled: false } + : { enabled: false }; +} + +/** + * Whether an `editorial` argument opted in, and into what. + * + * Read back off the argument for the same reason `ContentSearchEnabled` is: the + * whole object is inferred as one type parameter, and an intersection member is + * not an inference site, so this is the only way the literals survive. + */ +export type ContentEditorialEnabled = TEditorial extends { + enabled: true; +} + ? true + : false; + +export type ContentPreviewEnabled = TEditorial extends { + enabled: true; + preview: { enabled: true }; +} + ? true + : false; + +export type ContentSchedulingEnabled = TEditorial extends { + enabled: true; + scheduling: { enabled: true }; +} + ? true + : false; + +/** `editorial` after `defineContentType` has filled in every default. */ +export interface ResolvedContentEditorialConfig< + TEnabled extends boolean = boolean, + TPreview extends boolean = boolean, + TScheduling extends boolean = boolean, +> { + enabled: TEnabled; + preview: { + enabled: TPreview; + expiresInMinutes: number; + pathTemplate: null | string; + }; + revisions: { retention: number }; + scheduling: { enabled: TScheduling }; +} + +/** + * The one generated column `editorial` adds. + * + * Read-only on the wire like the publication columns: it appears in a response + * so a client knows what to send back as `expectedVersion`, and it is absent + * from the create and update schemas so nobody can write it. + */ +type ContentEditorialColumns = TDefinition extends { + editorial: { enabled: true }; +} + ? { version: number } + : Record; + // --------------------------------------------------------------------------- // Definition // --------------------------------------------------------------------------- @@ -644,6 +788,36 @@ export type PublicContentTypeDefinition = AnyContentTypeDefinition & { publicApi: { enabled: true }; }; +/** + * A content type with the editorial workflow: it has a `version` column, its + * writes are guarded by an expected version, and every real mutation leaves a + * revision behind. + * + * An intersection rather than three more type arguments, for the same reason + * {@link PublicContentTypeDefinition} is one. + */ +export type EditorialContentTypeDefinition = AnyContentTypeDefinition & { + editorial: { enabled: true }; +}; + +/** + * A content type whose drafts can be previewed. + * + * Both halves are pinned: the preview projects through `publicApi.fields`, so a + * content type without a public allowlist cannot reach the token signer at all. + */ +export type PreviewableContentTypeDefinition = EditorialContentTypeDefinition & + PublicContentTypeDefinition & { + editorial: { preview: { enabled: true } }; + }; + +/** A content type whose publication can be scheduled. */ +export type SchedulableContentTypeDefinition = + EditorialContentTypeDefinition & { + editorial: { scheduling: { enabled: true } }; + publication: { enabled: true }; + }; + export interface ContentTypeDefinition< TId extends string = string, TFields = ContentFieldMap, @@ -651,8 +825,17 @@ export interface ContentTypeDefinition< TPublicField extends string = string, TPublicEnabled extends boolean = boolean, TSearchEnabled extends boolean = boolean, + TEditorialEnabled extends boolean = boolean, + TPreviewEnabled extends boolean = boolean, + TSchedulingEnabled extends boolean = boolean, > { admin: ResolvedContentAdminConfig; + /** Editorial workflow, or the disabled default when `editorial` is omitted. */ + editorial: ResolvedContentEditorialConfig< + TEditorialEnabled, + TPreviewEnabled, + TSchedulingEnabled + >; fields: TFields; id: TId; /** Declared indexes plus the automatic ones, deduplicated and named. */ @@ -669,7 +852,10 @@ export interface ContentTypeDefinition< TPublication, TPublicField, TPublicEnabled, - TSearchEnabled + TSearchEnabled, + TEditorialEnabled, + TPreviewEnabled, + TSchedulingEnabled > >; /** Search synchronization, or the disabled default when `search` is omitted. */ @@ -687,11 +873,12 @@ export type ContentFieldsOf = TDefinition extends { : never; export type ContentSelect = Prettify< - ContentPublicationColumns & { - [K in keyof ContentFieldsOf]: ContentFieldValue< - ContentFieldsOf[K] - >; - } & { createdAt: Date; id: number; updatedAt: Date } + ContentEditorialColumns & + ContentPublicationColumns & { + [K in keyof ContentFieldsOf]: ContentFieldValue< + ContentFieldsOf[K] + >; + } & { createdAt: Date; id: number; updatedAt: Date } >; export type ContentCreateInput = Prettify< @@ -772,6 +959,9 @@ export type ContentFilterInput = Partial< export type ContentOrderableFieldName = | ContentFieldName | ContentSystemField + | (TDefinition extends { editorial: { enabled: true } } + ? ContentEditorialField + : never) | (TDefinition extends { publication: { enabled: true } } ? ContentPublicationField : never); diff --git a/packages/vitnode/src/database/content.ts b/packages/vitnode/src/database/content.ts new file mode 100644 index 000000000..5ed075993 --- /dev/null +++ b/packages/vitnode/src/database/content.ts @@ -0,0 +1,176 @@ +import { sql } from "drizzle-orm"; +import { index, pgTable, uniqueIndex } from "drizzle-orm/pg-core"; + +import type { + ContentRevisionSnapshot, + ContentSnapshotValue, +} from "../content/revisions"; + +import { + CONTENT_ACTOR_TYPES, + CONTENT_REVISION_OPERATIONS, + CONTENT_SCHEDULE_ACTIONS, + CONTENT_SCHEDULE_STATUSES, +} from "../content/const"; +import { core_users } from "./users"; + +/** + * Revision history for every content type with `editorial: { enabled: true }`. + * + * One shared table rather than one per content type: a content table is + * generated at runtime from a descriptor, so core's static schema cannot name + * it - and a per-type revision table would mean a second generated table and a + * second migration for every plugin, with no cross-type query left possible. + * + * There is deliberately **no foreign key to the record**, for the same reason + * `core_search_index` has none: the target table is not knowable here. The + * consequences are handled rather than ignored - every read is scoped by + * `(pluginId, contentTypeId, itemId)`, a delete leaves a final `delete` + * revision behind, and rows whose content type is no longer registered are + * swept up by the editorial cleanup job. + */ +export const core_content_revisions = pgTable( + "core_content_revisions", + t => ({ + id: t.serial().primaryKey(), + pluginId: t.varchar({ length: 255 }).notNull(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + /** The version the record holds *after* this mutation. */ + version: t.integer().notNull(), + operation: t + .varchar({ enum: CONTENT_REVISION_OPERATIONS, length: 20 }) + .notNull(), + snapshot: t + .jsonb() + .$type() + .notNull() + .default({} as ContentRevisionSnapshot), + /** Field names this mutation moved, so the history list needs no snapshot. */ + changedFields: t + .jsonb() + .$type() + .notNull() + .default([] as string[]), + actorType: t + .varchar({ enum: CONTENT_ACTOR_TYPES, length: 16 }) + .notNull() + .default("system"), + actorUserId: t.integer().references(() => core_users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + /** + * Set only on a `restore`. Intentionally **not** a foreign key: retention + * may prune the revision it names, and "restored from v7" is still true + * afterwards - a cascade would erase the fact, and a restrict would block + * pruning. + */ + restoredFromRevisionId: t.integer(), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + // One revision per version, enforced by the database rather than by code: + // this is what makes "exactly one revision per real mutation" true even + // under two concurrent writers, and it doubles as the history index, since + // `ORDER BY version DESC` for one record reads it directly. + // + // No `pluginId` in the key, deliberately. `validateContentTypes` rejects a + // duplicate content type id across *every* installed plugin at boot, so an + // id already identifies exactly one content type and one table - adding the + // owner would widen the index without excluding anything. It is still a + // column, because ownership is what the cleanup job keys off. + uniqueIndex("core_content_revisions_item_version_unique").on( + t.contentTypeId, + t.itemId, + t.version, + ), + index("core_content_revisions_plugin_id_idx").on(t.pluginId), + // Postgres does not index the child side of a foreign key on its own, and + // `ON DELETE SET NULL` scans it on every user deletion. + index("core_content_revisions_actor_user_id_idx").on(t.actorUserId), + ], +).enableRLS(); + +export type ContentRevisionRow = typeof core_content_revisions.$inferSelect; + +/** + * Pending and past scheduled transitions, for content types with + * `editorial.scheduling`. + * + * Shared and foreign-key-free for exactly the same reasons as + * {@link core_content_revisions}, and scoped by the same three columns on every + * query. + * + * Completed and cancelled rows are **kept**: "who scheduled this, and when did + * it go out" is the audit trail the feature exists to provide, and deleting it + * the moment it succeeds would answer that question with silence. A daily core + * cron sweeps them past `CONTENT_SCHEDULE_RETENTION_DAYS`. + */ +export const core_content_schedules = pgTable( + "core_content_schedules", + t => ({ + id: t.serial().primaryKey(), + pluginId: t.varchar({ length: 255 }).notNull(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + action: t.varchar({ enum: CONTENT_SCHEDULE_ACTIONS, length: 16 }).notNull(), + scheduledFor: t.timestamp().notNull(), + /** + * Bumped every time this `(item, action)` is rescheduled. + * + * The queued task carries the generation it was dispatched with, so a task + * left over from a previous schedule finds a mismatch and quietly does + * nothing. That is cheaper and far more reliable than trying to hunt down + * and delete the old queue row. + */ + generation: t.integer().notNull().default(1), + status: t + .varchar({ enum: CONTENT_SCHEDULE_STATUSES, length: 16 }) + .notNull() + .default("pending"), + /** The human who asked for it. A schedule is never created by the system. */ + createdBy: t.integer().references(() => core_users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + completedAt: t.timestamp(), + /** Why the last attempt failed. Set on an overdue row, cleared on success. */ + lastError: t.text(), + /** + * Why a *completed* schedule's announcements have not been delivered. + * + * The transition and its effects are two units of work on purpose, so they + * need two error fields. A value here means the record published exactly + * once and the event, search write or cache invalidation is still being + * retried by the `content-schedule-effects` task - never that the + * publication should run again. + */ + effectsError: t.text(), + }), + t => [ + // At most one *pending* schedule per record and action, enforced by the + // database. Rescheduling cancels the old row and inserts a new one in one + // transaction, so this is what makes "cancel, then insert" safe against a + // second request arriving between the two statements. + uniqueIndex("core_content_schedules_active_unique") + .on(t.contentTypeId, t.itemId, t.action) + .where(sql`status = 'pending'`), + // The queue worker's read: everything due, oldest first. + index("core_content_schedules_due_idx").on(t.status, t.scheduledFor), + index("core_content_schedules_item_idx").on(t.contentTypeId, t.itemId), + index("core_content_schedules_plugin_id_idx").on(t.pluginId), + index("core_content_schedules_created_by_idx").on(t.createdBy), + ], +).enableRLS(); + +export type ContentScheduleRow = typeof core_content_schedules.$inferSelect; + +/** Re-exported so `src/database` consumers need not reach into `content/`. */ +export type { ContentRevisionSnapshot, ContentSnapshotValue }; diff --git a/packages/vitnode/src/lib/api/signed-token.test.ts b/packages/vitnode/src/lib/api/signed-token.test.ts new file mode 100644 index 000000000..e06e1cb97 --- /dev/null +++ b/packages/vitnode/src/lib/api/signed-token.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { signPayload, verifySignedPayload } from "./signed-token"; + +const SECRET = "test-secret"; + +const schema = z.object({ id: z.number(), scope: z.string() }); + +describe("signPayload", () => { + it("round-trips a payload", () => { + const token = signPayload(SECRET, { id: 7, scope: "preview" }); + + expect(verifySignedPayload(SECRET, token, schema)).toEqual({ + id: 7, + scope: "preview", + }); + }); + + it("produces a URL-safe token", () => { + const token = signPayload(SECRET, { + id: 1, + scope: "a value with spaces & symbols?=/+", + }); + + // Anything outside this set would need escaping in a path segment, which + // means a link pasted into an email would arrive broken. + expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + }); + + it("is deterministic, so the same state yields the same link", () => { + const payload = { id: 7, scope: "preview" }; + + expect(signPayload(SECRET, payload)).toBe(signPayload(SECRET, payload)); + }); +}); + +describe("verifySignedPayload", () => { + it("rejects a different secret", () => { + const token = signPayload(SECRET, { id: 7, scope: "preview" }); + + expect(verifySignedPayload("another-secret", token, schema)).toBeNull(); + }); + + it("rejects a flipped signature byte", () => { + const token = signPayload(SECRET, { id: 7, scope: "preview" }); + const [body, signature] = token.split("."); + const flipped = `${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + + expect( + verifySignedPayload(SECRET, `${body}.${flipped}`, schema), + ).toBeNull(); + }); + + it("rejects an edited payload", () => { + // The whole point: the payload is readable, so it must not be trusted. + const forged = Buffer.from( + JSON.stringify({ id: 9999, scope: "preview" }), + "utf8", + ).toString("base64url"); + const signature = signPayload(SECRET, { id: 7, scope: "preview" }).split( + ".", + )[1]; + + expect( + verifySignedPayload(SECRET, `${forged}.${signature}`, schema), + ).toBeNull(); + }); + + it("rejects a payload of the wrong shape", () => { + const token = signPayload(SECRET, { nope: true }); + + expect(verifySignedPayload(SECRET, token, schema)).toBeNull(); + }); + + it.each([ + ["empty", ""], + ["no separator", "abcdef"], + ["two separators", "a.b.c"], + ["empty body", ".signature"], + ["empty signature", "body."], + ["not base64url", "!!!.???"], + ["not JSON", `${Buffer.from("nope", "utf8").toString("base64url")}.x`], + ])("returns null rather than throwing for %s input", (_name, token) => { + // A token arrives from a URL, so malformed input is ordinary rather than + // exceptional - a caller that has to wrap every call in `try` forgets to. + expect(() => verifySignedPayload(SECRET, token, schema)).not.toThrow(); + expect(verifySignedPayload(SECRET, token, schema)).toBeNull(); + }); + + it("rejects a signature of the wrong length without throwing", () => { + // `timingSafeEqual` throws on mismatched lengths, so the guard has to come + // first. This is the test that proves it does. + const [body] = signPayload(SECRET, { id: 7, scope: "preview" }).split("."); + + expect(verifySignedPayload(SECRET, `${body}.short`, schema)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/lib/api/signed-token.ts b/packages/vitnode/src/lib/api/signed-token.ts new file mode 100644 index 000000000..b8195f9fe --- /dev/null +++ b/packages/vitnode/src/lib/api/signed-token.ts @@ -0,0 +1,72 @@ +import type { z } from "zod"; + +import crypto from "node:crypto"; + +/** + * A payload and its HMAC, in one URL-safe string. + * + * ```text + * base64url(JSON.stringify(payload)) "." base64url(hmacSha256(secret, part1)) + * ``` + * + * Stateless on purpose: the alternative is an opaque id backed by a table, + * which buys revocation at the cost of a row per link and a write on every + * click. Where that trade is wrong, rotate the secret - it invalidates every + * outstanding token at once. + * + * This is **not** encryption. The payload is readable by anyone holding the + * token; the signature only proves nobody edited it. Never put a secret in one. + */ +const encodePayload = (payload: unknown): string => + Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); + +const sign = (secret: string, body: string): string => + crypto.createHmac("sha256", secret).update(body).digest("base64url"); + +export const signPayload = (secret: string, payload: unknown): string => { + const body = encodePayload(payload); + + return `${body}.${sign(secret, body)}`; +}; + +/** + * Reads a payload back, or returns `null`. + * + * Never throws, whatever arrives. A token comes out of a URL, so "not + * base64url", "not JSON", "no dot", "three dots" and "an object of the wrong + * shape" are all ordinary inputs rather than exceptional ones - and a caller + * that has to wrap every verification in `try` eventually forgets to. + * + * The signature is compared with `timingSafeEqual`, which needs both buffers to + * be the same length, so the length check comes first. That check leaks only + * the length of a hex-ish string that is always 43 characters for a valid + * token, which is to say nothing. + */ +export const verifySignedPayload = ( + secret: string, + token: string, + schema: z.ZodType, +): null | TValue => { + const parts = token.split("."); + if (parts.length !== 2) return null; + + const [body, signature] = parts; + if (!body || !signature) return null; + + const expected = Buffer.from(sign(secret, body), "utf8"); + const provided = Buffer.from(signature, "utf8"); + + if (expected.length !== provided.length) return null; + if (!crypto.timingSafeEqual(expected, provided)) return null; + + try { + const decoded: unknown = JSON.parse( + Buffer.from(body, "base64url").toString("utf8"), + ); + const parsed = schema.safeParse(decoded); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } +}; diff --git a/packages/vitnode/src/lib/config.ts b/packages/vitnode/src/lib/config.ts index bec34833e..c2c7222e8 100644 --- a/packages/vitnode/src/lib/config.ts +++ b/packages/vitnode/src/lib/config.ts @@ -6,6 +6,46 @@ export const INSECURE_DEFAULT_CRON_SECRET = "default-cron-secret-change-in-production"; +/** + * Fallback used when `CONTENT_PREVIEW_SECRET` is not set, and well-known for + * the same reason as the cron one: the integrations panel flags content preview + * as insecure while it is in use. + * + * The stakes are higher here than for cron. This secret is the *only* thing + * standing between an unpublished record and anyone who can guess a URL, so a + * deployment left on the default is one search away from publishing its drafts. + */ +export const INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET = + "default-content-preview-secret-change-in-production"; + +/** + * How much entropy a preview secret has to carry. + * + * 32 bytes is the block size HMAC-SHA256 keys are compared against, and it is + * what `openssl rand -base64 32` produces. Anything shorter is a password, and + * a password is not a signing key. + */ +export const CONTENT_PREVIEW_SECRET_MIN_BYTES = 32; + +/** + * Whether a value is good enough to sign preview links with. + * + * `false` for a missing secret, for the well-known fallback, and for anything + * too short to be worth attacking a hash with. Preview is the one feature in + * the engine whose entire access control is a signature, so a weak secret is + * not a warning - it is an unpublished record served to anyone who reads this + * source file. + * + * `TextEncoder` rather than `Buffer`, so the check runs unchanged in a browser + * bundle and in `drizzle-kit`. + */ +export const isSecureContentPreviewSecret = ( + secret: null | string | undefined, +): boolean => + typeof secret === "string" && + secret !== INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET && + new TextEncoder().encode(secret).length >= CONTENT_PREVIEW_SECRET_MIN_BYTES; + /** * Env is read lazily via getters, not captured at module load. The standalone * API loads its `.env` (dotenv) only when `vitnode.api.config.ts` runs, which can @@ -16,6 +56,12 @@ export const CONFIG = { get api(): URL { return new URL(process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"); }, + get contentPreviewSecret(): string { + return ( + process.env.CONTENT_PREVIEW_SECRET ?? + INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET + ); + }, get cronJobSecret(): string { return process.env.CRON_SECRET ?? INSECURE_DEFAULT_CRON_SECRET; }, diff --git a/packages/vitnode/src/lib/i18n/rich-message-call-sites.test.ts b/packages/vitnode/src/lib/i18n/rich-message-call-sites.test.ts new file mode 100644 index 000000000..711f4d90d --- /dev/null +++ b/packages/vitnode/src/lib/i18n/rich-message-call-sites.test.ts @@ -0,0 +1,92 @@ +// @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 SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const VIEWS = join(SRC, "views"); + +const messages = JSON.parse( + readFileSync(join(SRC, "locales/en.json"), "utf8"), +) as Record; + +const sourceFiles = (dir: string): string[] => { + const found: string[] = []; + + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + + if (statSync(path).isDirectory()) found.push(...sourceFiles(path)); + else if (/\.tsx?$/.test(path) && !path.includes(".test.")) found.push(path); + } + + return found; +}; + +const lookup = (path: string): string | undefined => { + let node: unknown = messages; + + for (const part of path.split(".")) { + if (typeof node !== "object" || node === null) return undefined; + node = (node as Record)[part]; + } + + return typeof node === "string" ? node : undefined; +}; + +/** `` and friends - a tag next-intl expects a function for. */ +const RICH_TAG = /<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>/; + +/** `const t = useTranslations("core.content.schedule")`, per variable name. */ +const SCOPE = + /(?:const|let)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\(\s*"([^"]+)"\s*\)/g; + +const mismatches = (): string[] => { + const found: string[] = []; + + for (const file of sourceFiles(VIEWS)) { + const source = readFileSync(file, "utf8"); + const scopes = new Map( + [...source.matchAll(SCOPE)].map(match => [match[1], match[2]]), + ); + if (scopes.size === 0) continue; + + // Only literal keys. A template literal is resolved at runtime, and a + // message chosen dynamically is not something this can reason about. + const call = new RegExp( + `\\b(${[...scopes.keys()].join("|")})(\\.rich)?\\(\\s*"([^"]+)"`, + "g", + ); + + for (const match of source.matchAll(call)) { + const [, variable, rich, key] = match; + const message = lookup(`${scopes.get(variable)}.${key}`); + + // Missing keys are a different bug, and plugin locales live elsewhere. + if (message === undefined || rich || !RICH_TAG.test(message)) continue; + + const line = source.slice(0, match.index).split("\n").length; + found.push( + `${relative(SRC, file)}:${line} - ${variable}("${key}") must be ${variable}.rich(...)`, + ); + } + } + + return found; +}; + +describe("rich message call sites", () => { + it("calls t.rich for every message that carries a tag", () => { + // A message like `Publish at a set time` needs a *function* + // for `title`. Handing `t()` a plain string throws + // `FORMATTING_ERROR: Value for "title" must be of type function` - at render + // time, in the browser, with nothing at compile time to stop it. + // + // No UI test catches this either: they all mock `useTranslations` with a + // `t` that ignores its arguments, which is the right trade for testing + // behaviour and exactly why this class of bug survives. So it is checked + // here, statically, against the real messages. + expect(mismatches()).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index c0c9c3bed..cf3c9e032 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -123,7 +123,7 @@ "noIndexer": "No rebuild indexer", "noIndexerDesc": "No rebuild indexer is registered. Documents may still be maintained by live plugin writes.", "removeDocuments": "Remove documents", - "removeConfirmTitle": "Remove indexed documents for \u201c{collection}\u201d?", + "removeConfirmTitle": "Remove indexed documents for “{collection}”?", "removeConfirmDescription": "No rebuild indexer is registered for this collection. Its documents may still be maintained by live plugin writes. This removes their current indexed state, and future writes may add them again.", "removeSuccess": "Removed the indexed documents for {collection}.", "removeSuccessDesc": "Live plugin writes may recreate them.", @@ -430,7 +430,11 @@ "title": "Delete {name}", "desc": "Are you sure you want to delete ? This action cannot be undone.", "confirm": "Yes, delete it", - "success": "{name} has been deleted." + "success": "{name} has been deleted.", + "conflict": { + "title": "This record changed", + "desc": "Someone saved it after this page loaded, so it was not deleted. Refresh the list, check what changed, and delete it again if you still want to." + } }, "publish": { "title": "Publish {name}", @@ -464,18 +468,105 @@ "off": "No" } }, + "conflict": { + "title": "Someone else saved this first", + "desc": "This {name, select, other {record}} moved to version {version} while you were editing. Nothing you typed has been lost.", + "reload": "Show what changed", + "reloaded": "Now at version {version}. Saving replaces the changes below with yours." + }, + "history": { + "title": "History of this {name}", + "desc": "Every version of this record, newest first.", + "empty": "No versions yet. The first edit will show up here.", + "current": "Current", + "system_actor": "System", + "changed_fields": "Changed: {fields}", + "show_changes": "Show changes", + "hide_changes": "Hide changes", + "no_changes": "No field values changed.", + "load_failed": "This version could not be loaded.", + "load_more": "Load older versions", + "loading_more": "Loading…", + "operations": { + "create": "Created", + "update": "Edited", + "publish": "Published", + "unpublish": "Unpublished", + "restore": "Restored", + "delete": "Deleted" + }, + "restore": { + "action": "Restore", + "title": "Restore version {version}?", + "desc": "This puts the field values of version {version} back on as a new version {nextVersion}. Nothing is deleted - every version in between stays in the history - and the publication state does not change.", + "confirm": "Restore this version", + "success": "{name} restored", + "success_desc": "Restored from version {version}." + } + }, + "preview": { + "title": "Preview link", + "desc": "A signed link to {title} as it is right now, including unpublished changes. Anyone who has the link can open it - no account needed.", + "loading": "Creating a link…", + "link": "Preview link", + "copy": "Copy link", + "open": "Open", + "expires": "Expires ", + "warning": "Treat it like a password: it works for anyone until it expires, and the only way to revoke one early is to rotate CONTENT_PREVIEW_SECRET. It is pinned to this version, so heavy editing can age it out of the history before it expires.", + "unavailable": "Preview is not configured on this deployment. Set CONTENT_PREVIEW_SECRET to at least 32 random bytes and restart the API.", + "live": "This record has no saved version yet, so the link shows it live - it will follow any edits made before the reviewer opens it." + }, + "schedule": { + "title": "Schedule this {name}", + "desc": "Publish or unpublish at a set time. Scheduling changes nothing now.", + "empty": "Nothing scheduled yet.", + "submit": "Schedule it", + "cancel": "Cancel", + "cancelled": "Schedule cancelled", + "success": "{name} scheduled", + "overdue": "Overdue - it has not run", + "precision": "Scheduled work runs on a one-minute tick, so expect up to a minute of delay. It publishes whatever the record says at that time, not what it says now.", + "actions": { + "publish": "Publish", + "unpublish": "Unpublish" + }, + "status": { + "pending": "Pending", + "completed": "Done", + "cancelled": "Cancelled" + }, + "field": { + "action": "What should happen", + "when": "When", + "when_desc": "In your timezone ({zone})." + }, + "no_cron": { + "title": "No scheduler is running", + "desc": "Schedules will be saved but will never fire, because this install has no cron adapter configured. Set one up, or trigger the cron endpoint from outside." + }, + "errors": { + "in_past": "That time has already passed. Pick a moment in the future.", + "order": "An unpublish has to come after the publish that is already scheduled.", + "unsupported": "This content type cannot be scheduled." + }, + "effects_failed": "Published, but the announcements did not go out yet - retrying." + }, "permissions": { "can_view": "View list", "can_create": "Create", "can_edit": "Edit", "can_delete": "Delete", - "can_publish": "Publish and unpublish" + "can_publish": "Publish and unpublish", + "can_restore": "Restore an earlier version" }, "errors": { "not_found": "This record no longer exists. Refresh the list and try again.", "validation": "Some of these values are not valid. Check the form and try again.", "conflict": "This record is still referenced by other content.", - "forbidden": "You do not have permission to do this." + "forbidden": "You do not have permission to do this.", + "version_conflict": "Someone else saved this while you were editing. Your changes are still here.", + "unique_conflict": "A record with these values already exists.", + "not_restorable": "This version cannot be restored: {fields} no longer fit this content type." } } }, @@ -944,6 +1035,13 @@ "stale": "No job has run in over 6 hours - cron may be misconfigured or stopped. Check your cron setup.", "jobs": "{count, plural, =0 {No jobs scheduled} one {# job scheduled} other {# jobs scheduled}}" }, + "content_preview": { + "title": "Content Preview", + "desc": "Signed, expiring links that let a reviewer read an unpublished record without an account.", + "insecure": "Using the default CONTENT_PREVIEW_SECRET - anyone can forge a preview link. Set a real one.", + "not_configured": "No content type has preview enabled.", + "content_types": "{count, plural, =0 {No content types} one {# content type} other {# content types}}" + }, "queue": { "title": "Queue Tasks", "desc": "Background jobs enqueued in the database and drained by the cron worker.", diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 7ee988755..e7d02012c 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -98,6 +98,65 @@ export const testPostContentType = defineContentType({ }, }); +/** + * The Stage 4 shape: `testPostContentType` plus the full editorial workflow. + * + * A separate fixture rather than a flag on the post, for the same reason the + * searchable one is separate: leaving the post exactly as it was is what proves + * a Stage 2 content type is untouched by editorial existing. + */ +export const testEditorialPostContentType = defineContentType({ + id: "test.editorial", + tableName: "test_editorial_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 }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + path: "editorial", + fields: ["title", "slug", "excerpt", "publishedAt"], + defaultOrderBy: "publishedAt", + }, + editorial: { + enabled: true, + revisions: { retention: 10 }, + preview: { + enabled: true, + expiresInMinutes: 30, + pathTemplate: "/editorial/preview/{token}", + }, + scheduling: { enabled: true }, + }, + admin: { + label: { plural: "Test Editorials", singular: "Test Editorial" }, + titleField: "title", + list: { + columns: ["status", "title", "version"], + defaultOrderBy: "version", + }, + }, +}); + +/** + * Editorial without publication or a public API - the "revisions stand alone" + * fixture. Neither preview nor scheduling is expressible here, which is the + * point. + */ +export const testEditorialNoteContentType = defineContentType({ + id: "test.note", + tableName: "test_notes", + fields: { + title: field.text({ required: true, maxLength: 200 }), + body: field.textarea({ nullable: true }), + }, + editorial: { enabled: true }, + admin: { label: { plural: "Test Notes", singular: "Test Note" } }, +}); + /** * The Stage 3 shape: `testPostContentType` plus `search`. * diff --git a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.test.tsx new file mode 100644 index 000000000..2636a66ab --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +vi.mock("next-intl", () => ({ + useTranslations: (namespace: string) => { + const t = (key: string) => `${namespace}.${key}`; + + return t; + }, +})); + +const { ConflictNotice } = await import("./conflict-notice"); + +const spec: ContentFormSpec = { + contentTypeId: "test.editorial", + fields: [ + { + kind: "text", + label: "Title", + name: "title", + nullable: false, + required: true, + }, + { + kind: "textarea", + label: "Excerpt", + name: "excerpt", + nullable: true, + required: false, + }, + ], + pluginId: "@vitnode/test", + titleField: "title", +}; + +const opened = { excerpt: "Original excerpt", id: 7, title: "Original title" }; + +let onReload: ReturnType Promise>>; + +beforeEach(() => { + onReload = vi.fn<() => Promise>().mockResolvedValue(undefined); +}); + +describe("ConflictNotice", () => { + it("names the version the record moved to", () => { + render( + , + ); + + expect(screen.getByText("core.content.conflict.title")).not.toBeNull(); + }); + + it("offers to show what changed, and asks before doing anything", async () => { + render( + , + ); + + // Nothing is reloaded, merged or overwritten until the editor asks. + expect(onReload).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText("core.content.conflict.reload")); + + await waitFor(() => { + expect(onReload).toHaveBeenCalledTimes(1); + }); + }); + + it("lists only the fields another session actually changed", () => { + render( + , + ); + + expect(screen.getByText("Title")).not.toBeNull(); + expect(screen.getByText("Someone else's title")).not.toBeNull(); + // Unchanged, so it is not noise in the list. + expect(screen.queryByText("Excerpt")).toBeNull(); + }); + + it("shows no diff when the remote record matches", () => { + render( + , + ); + + expect(screen.queryByText("Title")).toBeNull(); + }); + + it("renders an absent value as the empty marker rather than 'undefined'", () => { + render( + , + ); + + expect(screen.getByText("Excerpt")).not.toBeNull(); + expect(screen.queryByText("undefined")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx new file mode 100644 index 000000000..240e2f187 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx @@ -0,0 +1,140 @@ +// No "use client" here on purpose, for the same reason `content-form` has +// none: this is only reached from a client entry, and declaring it again would +// make it a nested one that `next/dynamic` cannot resolve from a package. +import { TriangleAlertIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +export interface ContentConflictState { + currentVersion: number; + /** The record as it is now, once the editor asked for it. */ + latest?: Record; +} + +const asText = (value: unknown): string => { + if (value === null || value === undefined || value === "") return "—"; + if (value instanceof Date) return value.toISOString(); + + // A row arrives as JSON, so a value is a primitive or it is something the + // comparison has no opinion about - stringifying an object would compare + // "[object Object]" against itself and report every row as unchanged. + switch (typeof value) { + case "bigint": + case "boolean": + case "number": + case "string": + return String(value); + default: + return JSON.stringify(value) ?? "—"; + } +}; + +/** + * What another session changed while this dialog was open. + * + * Compared against the values the dialog *opened* with, not against what the + * editor has typed since - the question being answered is "what did I not see", + * and mixing in unsaved edits would answer a different one. + */ +const RemoteChanges = ({ + latest, + opened, + spec, +}: { + latest: Record; + opened: Record; + spec: ContentFormSpec; +}) => { + const changed = spec.fields.filter( + field => asText(latest[field.name]) !== asText(opened[field.name]), + ); + + if (changed.length === 0) return null; + + return ( +
    + {changed.map(field => ( +
  • + {field.label} + + {asText(opened[field.name])} + + + {asText(latest[field.name])} +
  • + ))} +
+ ); +}; + +/** + * The lost-update banner. + * + * Three rules, and the reason this is a banner rather than a toast: + * + * 1. **Nothing the editor typed is discarded.** The form stays mounted; only + * this notice appears above it. + * 2. **Nothing is overwritten automatically.** Reloading shows what changed and + * arms the submit button with the *new* version - saving again is then a + * deliberate second click, not a silent clobber. + * 3. **No field merging.** Deciding which side of a conflicting paragraph wins + * is the editor's call, and guessing it is worse than asking. + */ +export const ConflictNotice = ({ + conflict, + onReload, + opened, + spec, +}: { + conflict: ContentConflictState; + onReload: () => Promise; + opened: Record; + spec: ContentFormSpec; +}) => { + const t = useTranslations("core.content.conflict"); + const [loading, setLoading] = React.useState(false); + + return ( + + + {t("title")} + + {conflict.latest ? ( + <> +

{t("reloaded", { version: conflict.currentVersion })}

+ + + ) : ( + <> +

{t("desc", { version: conflict.currentVersion })}

+ + + )} +
+
+ ); +}; 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 f83e51da4..f3e271fe5 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 @@ -21,12 +21,16 @@ import { } from "@/content/admin/spec"; import { usePathname, useRouter } from "@/lib/navigation"; +import type { ContentConflictState } from "./conflict-notice"; + import { ContentField } from "../lib/field-component"; import { contentErrorKey } from "../lib/mutation-feedback"; +import { ConflictNotice } from "./conflict-notice"; import { createContentAction, editContentAction, loadContentOptionsAction, + reloadContentRowAction, } from "./mutation-api.server"; /** @@ -97,25 +101,65 @@ export const ContentForm = ({ const { setOpen } = useDialog(); const { push } = useRouter(); const pathname = usePathname(); + const [conflict, setConflict] = React.useState( + null, + ); + + // The version this dialog opened with, and the one every save is checked + // against - until a conflict is resolved, which replaces it with the version + // the editor has now actually seen. + const [expectedVersion, setExpectedVersion] = React.useState(() => + typeof data?.version === "number" ? data.version : undefined, + ); const formSchema = React.useMemo( () => buildFormSchemaFromSpec(spec, data), [spec, data], ); + const onReload = async () => { + const { row } = await reloadContentRowAction( + spec.contentTypeId, + data?.id ?? 0, + ); + if (!row) return; + + setConflict({ + currentVersion: typeof row.version === "number" ? row.version : 0, + latest: row, + }); + // Saving again now overwrites what the editor has just been shown, which is + // a decision they make by pressing the button a second time. + if (typeof row.version === "number") setExpectedVersion(row.version); + }; + const onSubmit: AutoFormOnSubmit = async values => { // Relation and user fields hold the whole combobox option; the API wants // the identifier. const payload = contentFormValuesToPayload(spec, values); const mutation = data - ? await editContentAction(spec.contentTypeId, data.id, payload) + ? await editContentAction( + spec.contentTypeId, + data.id, + payload, + expectedVersion, + ) : await createContentAction(spec.contentTypeId, payload); if (mutation.error !== undefined) { + // A lost update is the one failure with somewhere to go: the dialog stays + // open with everything the editor typed, and the banner offers to show + // what changed underneath them. + if (mutation.conflict?.code === "CONTENT_VERSION_CONFLICT") { + setConflict({ currentVersion: mutation.conflict.currentVersion }); + + return; + } + // A validation failure, a conflicting row and a server fault all need // different words - and none of them may quote the database. - const errorKey = contentErrorKey(mutation.status); + const errorKey = contentErrorKey(mutation.status, mutation); toast.error(tErrors("title"), { description: errorKey @@ -152,6 +196,15 @@ export const ContentForm = ({ /> ) : null} + {conflict && data ? ( + + ) : null} + ({ id: fieldSpec.name, diff --git a/packages/vitnode/src/views/admin/views/content/actions/delete-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/delete-action.tsx index 8565d62da..133a5b5a3 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/delete-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/delete-action.tsx @@ -25,6 +25,7 @@ export const DeleteContentAction = ({ pluginId, singular, title, + version, }: { contentTypeId: string; id: number; @@ -32,6 +33,11 @@ export const DeleteContentAction = ({ pluginId: string; singular: string; title: string; + /** + * The version this row showed. `undefined` for a content type without + * `editorial`, whose delete has no precondition and never had one. + */ + version?: number; }) => { const t = useTranslations("core.content.delete"); const tErrors = useTranslations("core.global.errors"); @@ -56,9 +62,25 @@ export const DeleteContentAction = ({ ), })} onSubmit={async ({ onClose }) => { - const mutation = await deleteContentAction(contentTypeId, id); + const mutation = await deleteContentAction( + contentTypeId, + id, + version, + ); if (mutation.error !== undefined) { + // Someone saved while this dialog was open. Deliberately *not* + // retried with the new version: the whole point of the + // precondition is that the person confirms deleting the record as + // it is now, and they have not seen what changed. + if (mutation.conflict?.code === "CONTENT_VERSION_CONFLICT") { + toast.error(t("conflict.title"), { + description: t("conflict.desc"), + }); + + return; + } + // A restricted delete (409) is a normal, explainable outcome; an // unrecognised status is a server fault and reads as one. const errorKey = contentErrorKey(mutation.status); diff --git a/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx new file mode 100644 index 000000000..0d09ab988 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { HistoryIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +// A history body carries the diff renderer and every revision it opens, so it +// is loaded when the dialog is - the same treatment the edit form gets. +const RevisionHistory = dynamic(async () => + import("./history/revision-history").then(mod => ({ + default: mod.RevisionHistory, + })), +); + +/** + * The revision-history row action. + * + * Gated by `can_view`, not `can_restore`: reading what changed is part of + * seeing the record at all, and a role that can look but not roll back is a + * reasonable one. The restore button inside checks `can_restore` itself. + */ +export const HistoryContentAction = ({ + contentTypeId, + currentVersion, + id, + permissionModule, + pluginId, + singular, + spec, + title, +}: { + contentTypeId: string; + currentVersion: number; + id: number; + permissionModule: string; + pluginId: string; + singular: string; + spec: ContentFormSpec; + title: string; +}) => { + const t = useTranslations("core.content.history"); + const canView = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + }); + + if (!canView) return null; + + const label = t("title", { name: singular }); + + return ( + + + + + + + } + /> + } + /> + + + + {label} + {t("desc")} + + + }> + + + + + + {label} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx new file mode 100644 index 000000000..23b302765 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx @@ -0,0 +1,184 @@ +// No "use client": reached only from `history-action`, which is a client entry. +import { useTranslations } from "next-intl"; +import React from "react"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { + ContentRevisionSnapshot, + ContentSnapshotValue, +} from "@/content/revisions"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { contentRevisionDiff } from "@/content/revisions"; + +/** How many lines of a `textarea` diff are shown before it collapses. */ +const TEXTAREA_PREVIEW_LINES = 8; + +const Empty = ({ label }: { label: string }) => ( + + — + +); + +/** + * One value, rendered the way its field kind reads best. + * + * Raw JSON is deliberately not the default anywhere: an editor comparing two + * versions of an article is looking for a sentence that changed, and + * `{"title":"..."}` makes them find it themselves. + */ +const Value = ({ + emptyLabel, + kind, + labels, + options, + value, +}: { + emptyLabel: string; + kind: string; + /** Resolved display names for relation and user identifiers. */ + labels: Record; + options?: Record; + value: ContentSnapshotValue | undefined; +}) => { + if (value === null || value === undefined || value === "") { + return ; + } + + switch (kind) { + case "boolean": + return {value === true ? "✓" : "—"}; + + case "dateTime": + return ; + + case "enum": + return ( + + {options?.[String(value)] ?? String(value)} + + ); + + case "number": + return {String(value)}; + + // A snapshot stores the foreign key, never a label - the label belongs to + // another content type and may not even be public. It is resolved for + // display only, and falls back to the identifier. + case "relation": + case "user": + return {labels[String(value)] ?? `#${String(value)}`}; + + case "textarea": { + const text = String(value); + const lines = text.split("\n"); + + return lines.length > TEXTAREA_PREVIEW_LINES ? ( +
+ + {lines.slice(0, TEXTAREA_PREVIEW_LINES).join("\n")} + + {text} +
+ ) : ( + {text} + ); + } + + default: + return {String(value)}; + } +}; + +/** + * Field-level differences between two snapshots. + * + * Walks the content type's *current* fields, so a field dropped since the + * snapshot was taken is absent rather than shown as "changed to nothing" - + * which matches what a restore would actually do with it. + */ +export const RevisionDiff = ({ + after, + before, + labels = {}, + spec, +}: { + after: ContentRevisionSnapshot; + before: ContentRevisionSnapshot | null; + labels?: Record; + spec: ContentFormSpec; +}) => { + const t = useTranslations("core.content"); + const byName = React.useMemo( + () => + new Map( + spec.fields.map(field => [ + field.name, + { + ...field, + // The form spec carries picker options as a list; the renderer wants + // a lookup from the stored value to its label. + options: Object.fromEntries( + (field.options ?? []).map(option => [option.value, option.label]), + ), + }, + ]), + ), + [spec], + ); + + const entries = React.useMemo( + () => + contentRevisionDiff( + spec.fields.map(field => field.name), + before, + after, + ), + [after, before, spec], + ); + + if (entries.length === 0) { + return ( +

{t("history.no_changes")}

+ ); + } + + const emptyLabel = t("table.empty_value"); + + return ( +
+ {entries.map(entry => { + const field = byName.get(entry.name); + const kind = field?.kind ?? "text"; + + return ( +
+
+ {field?.label ?? entry.name} +
+
+ + + + + +
+
+ ); + })} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.test.tsx new file mode 100644 index 000000000..125aa93f2 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.test.tsx @@ -0,0 +1,418 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentRevisionMeta } from "@/content/revisions"; + +vi.mock("next-intl", () => { + const useTranslations = (namespace: string) => { + const t = (key: string) => `${namespace}.${key}`; + t.rich = (key: string) => `${namespace}.${key}`; + + return t; + }; + + return { useTranslations }; +}); + +// Locale formatting is not what this suite is about, and the real component +// pulls in `useFormatter`/`useNow` from the provider tree. +vi.mock("@/components/date-format", () => ({ + DateFormat: ({ date }: { date: Date | string }) => ( + {String(date)} + ), +})); + +const push = vi.fn(); +vi.mock("@/lib/navigation", () => ({ + usePathname: () => "/admin/content/test/editorial", + useRouter: () => ({ push }), +})); + +let canRestore = true; +vi.mock("@/components/staff-permission/provider", () => ({ + useAdminStaffPermission: () => canRestore, +})); + +const getContentRevisionAction = vi.fn(); +const listContentRevisionsAction = vi.fn(); +const restoreContentRevisionAction = vi.fn(); +vi.mock("../mutation-api.server", () => ({ + getContentRevisionAction: (...args: unknown[]) => + getContentRevisionAction(...args), + listContentRevisionsAction: (...args: unknown[]) => + listContentRevisionsAction(...args), + restoreContentRevisionAction: (...args: unknown[]) => + restoreContentRevisionAction(...args), +})); + +// The diff renderer has its own suite. Here the question is only *which* +// snapshots reach it, so it reports them and nothing else. +vi.mock("./revision-diff", () => ({ + RevisionDiff: ({ + after, + before, + }: { + after: { version: number }; + before: null | { version: number }; + }) => ( + {`diff ${before ? `v${before.version}` : "none"} → v${after.version}`} + ), +})); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +const { RevisionHistory } = await import("./revision-history"); + +const spec: ContentFormSpec = { + contentTypeId: "test.editorial", + fields: [ + { + kind: "text", + label: "Title", + name: "title", + nullable: false, + required: true, + }, + ], + pluginId: "@vitnode/test", + titleField: "title", +}; + +const revision = (version: number): ContentRevisionMeta => ({ + actorName: "Ada", + actorType: "staff", + actorUserId: 1, + changedFields: ["title"], + createdAt: new Date("2026-08-01T00:00:00.000Z"), + id: 1000 + version, + operation: "update", + restoredFromRevisionId: null, + version, +}); + +const page = ( + versions: number[], + { hasNextPage = false }: { hasNextPage?: boolean } = {}, +) => ({ + edges: versions.map(revision), + pageInfo: { endCursor: versions.at(-1) ?? null, hasNextPage }, +}); + +/** The detail route, answering for whichever revision id it was asked about. */ +const detailFor = ( + _contentTypeId: string, + _id: number, + revisionId: number, +) => ({ + revision: { + ...revision(revisionId - 1000), + snapshot: { version: revisionId - 1000 }, + }, +}); + +const view = () => + render( + , + ); + +beforeEach(() => { + vi.clearAllMocks(); + canRestore = true; + getContentRevisionAction.mockImplementation( + async (...args: unknown[]) => + await Promise.resolve(detailFor(...(args as [string, number, number]))), + ); +}); + +describe("RevisionHistory", () => { + it("shows the first page", async () => { + listContentRevisionsAction.mockResolvedValue(page([50, 49])); + + view(); + + expect(await screen.findByText("v50")).not.toBeNull(); + expect(screen.getByText("v49")).not.toBeNull(); + }); + + it("offers another page only when there is one", async () => { + listContentRevisionsAction.mockResolvedValue(page([50, 49])); + + view(); + + await screen.findByText("v50"); + expect(screen.queryByText("core.content.history.load_more")).toBeNull(); + }); + + it("appends the next page instead of replacing what is on screen", async () => { + // The whole point: the default retention is 50 and the default page is 25, + // so half the history used to be unreachable. + listContentRevisionsAction + .mockResolvedValueOnce(page([50, 49], { hasNextPage: true })) + .mockResolvedValueOnce(page([48, 47])); + + view(); + fireEvent.click(await screen.findByText("core.content.history.load_more")); + + await waitFor(() => { + expect(screen.getByText("v47")).not.toBeNull(); + }); + // Still there. Replacing would lose the versions the reader scrolled past. + expect(screen.getByText("v50")).not.toBeNull(); + }); + + it("asks for the next page from the last version it has", async () => { + listContentRevisionsAction + .mockResolvedValueOnce(page([50, 49], { hasNextPage: true })) + .mockResolvedValueOnce(page([48, 47])); + + view(); + fireEvent.click(await screen.findByText("core.content.history.load_more")); + + await waitFor(() => { + expect(listContentRevisionsAction).toHaveBeenCalledWith( + "test.editorial", + 7, + 49, + ); + }); + }); + + it("hides the button once the last page arrives", async () => { + listContentRevisionsAction + .mockResolvedValueOnce(page([50, 49], { hasNextPage: true })) + .mockResolvedValueOnce(page([48, 47])); + + view(); + fireEvent.click(await screen.findByText("core.content.history.load_more")); + + await waitFor(() => { + expect(screen.queryByText("core.content.history.load_more")).toBeNull(); + }); + }); + + it("never shows the same revision twice", async () => { + // Belt and braces on top of the exclusive cursor: a server that repeated + // the boundary row must not produce a duplicate React key or a duplicate + // line for a reader. + listContentRevisionsAction + .mockResolvedValueOnce(page([50, 49], { hasNextPage: true })) + .mockResolvedValueOnce(page([49, 48])); + + view(); + fireEvent.click(await screen.findByText("core.content.history.load_more")); + + await waitFor(() => { + expect(screen.getByText("v48")).not.toBeNull(); + }); + expect(screen.getAllByText("v49")).toHaveLength(1); + }); + + describe("the snapshot a row compares against", () => { + const expand = async (version: number) => { + const rows = await screen.findAllByText( + "core.content.history.show_changes", + ); + // Rows render newest first, so v50 is index 0. + fireEvent.click(rows[50 - version]); + }; + + it("loads the snapshot only when the row is expanded", async () => { + // A long article's every historical body, downloaded to render a list of + // dates, is the thing this avoids. + listContentRevisionsAction.mockResolvedValue(page([50, 49])); + + view(); + await screen.findByText("v50"); + + expect(getContentRevisionAction).not.toHaveBeenCalled(); + }); + + it("compares against the revision below it", async () => { + listContentRevisionsAction.mockResolvedValue(page([50, 49])); + + view(); + await expand(50); + + expect(await screen.findByText("diff v49 → v50")).not.toBeNull(); + }); + + it("has nothing to compare against at the end of a page", async () => { + listContentRevisionsAction.mockResolvedValue( + page([50, 49], { hasNextPage: true }), + ); + + view(); + await expand(49); + + expect(await screen.findByText("diff none → v49")).not.toBeNull(); + }); + + it("fills that diff in once the next page arrives", async () => { + // The boundary case. `previousId` goes from null to a real id while the + // row is open, and the reader should not have to close and reopen it to + // find out what actually changed. + listContentRevisionsAction + .mockResolvedValueOnce(page([50, 49], { hasNextPage: true })) + .mockResolvedValueOnce(page([48, 47])); + + view(); + await expand(49); + await screen.findByText("diff none → v49"); + + fireEvent.click(screen.getByText("core.content.history.load_more")); + + expect(await screen.findByText("diff v48 → v49")).not.toBeNull(); + }); + + it("keeps the row open while it does", async () => { + listContentRevisionsAction + .mockResolvedValueOnce(page([50, 49], { hasNextPage: true })) + .mockResolvedValueOnce(page([48, 47])); + + view(); + await expand(49); + await screen.findByText("diff none → v49"); + + fireEvent.click(screen.getByText("core.content.history.load_more")); + + // Never replaced by a spinner: the snapshot it already has stays on + // screen while the missing one is fetched behind it. + await waitFor(() => { + expect(screen.getByText("diff v48 → v49")).not.toBeNull(); + }); + expect(screen.queryByText("core.content.history.load_failed")).toBeNull(); + }); + + it("does not re-fetch the snapshot it already has", async () => { + listContentRevisionsAction + .mockResolvedValueOnce(page([50, 49], { hasNextPage: true })) + .mockResolvedValueOnce(page([48, 47])); + + view(); + await expand(49); + await screen.findByText("diff none → v49"); + expect(getContentRevisionAction).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText("core.content.history.load_more")); + await screen.findByText("diff v48 → v49"); + + // One more call, for the newly-available previous - not two. + expect(getContentRevisionAction).toHaveBeenCalledTimes(2); + expect(getContentRevisionAction).toHaveBeenLastCalledWith( + "test.editorial", + 7, + 1048, + ); + }); + + it("does not fetch anything for a row that was never opened", async () => { + listContentRevisionsAction + .mockResolvedValueOnce(page([50, 49], { hasNextPage: true })) + .mockResolvedValueOnce(page([48, 47])); + + view(); + fireEvent.click( + await screen.findByText("core.content.history.load_more"), + ); + await screen.findByText("v47"); + + expect(getContentRevisionAction).not.toHaveBeenCalled(); + }); + }); + + it("shows an error rather than an empty list", async () => { + listContentRevisionsAction.mockResolvedValue({ + edges: [], + error: "The API is unhappy", + pageInfo: { endCursor: null, hasNextPage: false }, + }); + + view(); + + expect(await screen.findByText("The API is unhappy")).not.toBeNull(); + }); + + describe("after a restore", () => { + const restore = async () => { + listContentRevisionsAction.mockResolvedValue(page([50, 49])); + restoreContentRevisionAction.mockResolvedValue({ version: 51 }); + + view(); + // The confirmation dialog's trigger, on the older revision. + fireEvent.click( + (await screen.findAllByText("core.content.history.restore.action"))[0], + ); + fireEvent.click( + await screen.findByText("core.content.history.restore.confirm"), + ); + }; + + it("posts the version the record currently holds", async () => { + await restore(); + + await waitFor(() => { + expect(restoreContentRevisionAction).toHaveBeenCalledWith( + "test.editorial", + 7, + 1049, + 50, + ); + }); + }); + + it("reloads the history, so the restore's own revision shows up", async () => { + await restore(); + + await waitFor(() => { + // Once on mount, once after the restore. + expect(listContentRevisionsAction).toHaveBeenCalledTimes(2); + }); + }); + + it("refreshes the table behind the dialog", async () => { + await restore(); + + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/admin/content/test/editorial"); + }); + }); + + it("uses the new version for the next restore", async () => { + // Reusing the version the dialog opened with would conflict with the + // restore it just performed. + await restore(); + + await waitFor(() => { + expect(restoreContentRevisionAction).toHaveBeenCalledTimes(1); + }); + + fireEvent.click( + (await screen.findAllByText("core.content.history.restore.action"))[0], + ); + fireEvent.click( + await screen.findByText("core.content.history.restore.confirm"), + ); + + await waitFor(() => { + expect(restoreContentRevisionAction).toHaveBeenLastCalledWith( + "test.editorial", + 7, + 1049, + 51, + ); + }); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx new file mode 100644 index 000000000..cda9a5dff --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx @@ -0,0 +1,427 @@ +// No "use client": reached only from `history-action`, which is a client entry. +import { HistoryIcon, RotateCcwIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { + ContentRevisionDetail, + ContentRevisionMeta, + ContentRevisionOperation, +} from "@/content/revisions"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { DateFormat } from "@/components/date-format"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Loader } from "@/components/ui/loader"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { usePathname, useRouter } from "@/lib/navigation"; + +import { contentErrorKey } from "../../lib/mutation-feedback"; +import { + getContentRevisionAction, + listContentRevisionsAction, + restoreContentRevisionAction, +} from "../mutation-api.server"; +import { RevisionDiff } from "./revision-diff"; + +interface RevisionHistoryProps { + contentTypeId: string; + /** The record's current version, for the restore precondition. */ + currentVersion: number; + id: number; + permissionModule: string; + pluginId: string; + singular: string; + spec: ContentFormSpec; + title: string; +} + +/** `create` reads better as "Created" than as a raw operation name. */ +const OperationBadge = ({ + operation, +}: { + operation: ContentRevisionOperation; +}) => { + const t = useTranslations("core.content.history.operations"); + + return ( + + {t(operation)} + + ); +}; + +/** + * One expandable row: metadata always, the snapshot only once opened. + * + * Loading every snapshot up front would mean shipping every historical version + * of a long article to the browser to render a list of dates. + */ +const RevisionRow = ({ + canRestore, + contentTypeId, + currentVersion, + id, + isCurrent, + onRestored, + previousId, + revision, + singular, + spec, + title, +}: { + canRestore: boolean; + contentTypeId: string; + currentVersion: number; + id: number; + isCurrent: boolean; + onRestored: (nextVersion?: number) => void; + previousId: null | number; + revision: ContentRevisionMeta; + singular: string; + spec: ContentFormSpec; + title: string; +}) => { + const t = useTranslations("core.content.history"); + const tErrors = useTranslations("core.global.errors"); + const tContentErrors = useTranslations("core.content.errors"); + const [detail, setDetail] = React.useState( + null, + ); + const [previous, setPrevious] = React.useState( + null, + ); + // Whether a fetch has come back at all, which is the only way to tell "still + // loading" from "loaded, and there was nothing there". Derived rather than a + // `loading` flag so nothing sets state synchronously inside the effect. + const [settled, setSettled] = React.useState(false); + const [open, setOpen] = React.useState(false); + + // Fetches whichever snapshots are missing, and only those. + // + // An effect rather than a click handler because the pair this row needs can + // change while it is open: the last row of a page has nothing below it, so + // its diff opens with no "before" - and **Load older versions** then puts one + // there. Loading on click alone would leave that row comparing against + // nothing until it was closed and reopened. + React.useEffect(() => { + if (!open) return; + + const needsDetail = detail === null; + // Not "is it null" but "is it the right one": the boundary row's previous + // arrives one page late, and re-fetching a snapshot already on screen is + // wasted work. + const needsPrevious = previousId !== null && previous?.id !== previousId; + if (!needsDetail && !needsPrevious) return; + + let active = true; + + void Promise.all([ + needsDetail + ? getContentRevisionAction(contentTypeId, id, revision.id) + : null, + needsPrevious + ? getContentRevisionAction(contentTypeId, id, previousId) + : null, + ]).then(([current, earlier]) => { + if (!active) return; + + // Both in one batch, so the diff never renders for a frame with its + // "before" still missing and every field looking newly added. + if (current) setDetail(current.revision ?? null); + if (earlier) setPrevious(earlier.revision ?? null); + setSettled(true); + }); + + return () => { + active = false; + }; + }, [contentTypeId, detail, id, open, previous?.id, previousId, revision.id]); + + return ( +
  • +
    + v{revision.version} + + {isCurrent ? {t("current")} : null} + + + {revision.actorName ?? t("system_actor")} + + + + + +
    + + + {canRestore && !isCurrent ? ( + ( + {title} + ), + version: revision.version, + })} + onSubmit={async ({ onClose }) => { + const mutation = await restoreContentRevisionAction( + contentTypeId, + id, + revision.id, + currentVersion, + ); + + if (mutation.error !== undefined) { + const errorKey = contentErrorKey(mutation.status, mutation); + + toast.error(tErrors("title"), { + description: errorKey + ? tContentErrors(errorKey) + : tErrors("internal_server_error"), + }); + + // Left open, so the reason stays next to the thing that + // failed - the same behaviour as delete and publish. + return; + } + + toast.success(t("restore.success", { name: singular }), { + description: t("restore.success_desc", { + version: revision.version, + }), + }); + onClose(); + // The new version travels back so the next restore in this + // still-open dialog posts the right precondition. + onRestored(mutation.version); + }} + textSubmit={t("restore.confirm")} + title={t("restore.title", { version: revision.version })} + > + + + ) : null} +
    +
    + + {revision.changedFields.length > 0 ? ( +

    + {t("changed_fields", { + fields: revision.changedFields.join(", "), + })} +

    + ) : null} + + {open ? ( + // Keyed on the snapshot rather than on a loading flag, so a later fetch + // of the missing "before" refines the diff in place instead of + // replacing it with a spinner somebody has to wait out again. + detail ? ( + + ) : settled ? ( +

    {t("load_failed")}

    + ) : ( + + ) + ) : null} +
  • + ); +}; + +interface HistoryState { + edges: ContentRevisionMeta[]; + endCursor: null | number; + error: null | string; + hasNextPage: boolean; + loaded: boolean; +} + +const EMPTY: HistoryState = { + edges: [], + endCursor: null, + error: null, + hasNextPage: false, + loaded: false, +}; + +export const RevisionHistory = ({ + contentTypeId, + currentVersion, + id, + permissionModule, + pluginId, + singular, + spec, + title, +}: RevisionHistoryProps) => { + const t = useTranslations("core.content.history"); + const { push } = useRouter(); + const pathname = usePathname(); + const [state, setState] = React.useState(EMPTY); + const [loadingMore, setLoadingMore] = React.useState(false); + // The version the record holds *now*, which stops being the prop the moment + // a restore succeeds - the dialog stays open, and the next restore needs the + // new precondition or it conflicts with the one just performed. + const [version, setVersion] = React.useState(currentVersion); + const canRestore = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.restore, + plugin: pluginId, + }); + + React.useEffect(() => { + let active = true; + + void listContentRevisionsAction(contentTypeId, id).then(result => { + if (!active) return; + + setState({ + edges: result.edges, + endCursor: result.pageInfo.endCursor, + error: result.error ?? null, + hasNextPage: result.pageInfo.hasNextPage, + loaded: true, + }); + }); + + return () => { + active = false; + }; + }, [contentTypeId, id]); + + /** Appends the next page. The cursor is exclusive, so nothing repeats. */ + const loadMore = async () => { + if (state.endCursor === null) return; + setLoadingMore(true); + + const result = await listContentRevisionsAction( + contentTypeId, + id, + state.endCursor, + ); + + setState(previous => { + if (result.error) return { ...previous, error: result.error }; + + // Belt and braces against a revision arriving between two page requests: + // the exclusive cursor already prevents a repeat, and this makes the list + // provably duplicate-free whatever the server sent. + const seen = new Set(previous.edges.map(edge => edge.id)); + + return { + ...previous, + edges: [ + ...previous.edges, + ...result.edges.filter(edge => !seen.has(edge.id)), + ], + endCursor: result.pageInfo.endCursor ?? previous.endCursor, + error: null, + hasNextPage: result.pageInfo.hasNextPage, + }; + }); + setLoadingMore(false); + }; + + /** Reloads the first page, so the restore's own revision shows up. */ + const reload = async (nextVersion?: number) => { + if (nextVersion !== undefined) setVersion(nextVersion); + + const result = await listContentRevisionsAction(contentTypeId, id); + + setState({ + edges: result.edges, + endCursor: result.pageInfo.endCursor, + error: result.error ?? null, + hasNextPage: result.pageInfo.hasNextPage, + loaded: true, + }); + + // The table behind the dialog is now wrong too. + push(pathname); + }; + + if (!state.loaded) return ; + + if (state.edges.length === 0) { + return ( +
    + +

    + {state.error ?? t("empty")} +

    +
    + ); + } + + return ( +
    +
      + {state.edges.map((revision, index) => ( + { + void reload(nextVersion); + }} + // The list is newest first, so the previous version is the next + // entry - except at the end of a page that has more behind it, + // where the diff has nothing to compare against yet. + previousId={state.edges[index + 1]?.id ?? null} + revision={revision} + singular={singular} + spec={spec} + title={title} + /> + ))} +
    + + {state.error ? ( +

    {state.error}

    + ) : null} + + {state.hasNextPage ? ( + + ) : null} +
    + ); +}; 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 322d1d131..2e378eedb 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,12 +3,30 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; +import type { + ContentConflict, + ContentScheduleRejection, + ContentUnprocessable, +} from "@/content/conflicts"; import type { ContentInvalidationMode } from "@/content/next/revalidate.server"; +import type { + ContentRevisionDetail, + ContentRevisionMeta, +} from "@/content/revisions"; +import type { + ContentSchedule, + ContentScheduleAction, +} from "@/content/schedules"; 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 { + parseContentConflict, + parseContentScheduleRejection, + parseContentUnprocessable, +} from "@/content/conflicts"; import { CONTENT_OPTIONS_LIMIT } from "@/content/const"; import { revalidateContent } from "@/content/next/revalidate.server"; @@ -20,11 +38,34 @@ const CONTENT_PAGE_PATH = "/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]"; interface MutationResult { + /** + * The structured reason an editorial write was refused, when the API sent + * one. `CONTENT_VERSION_CONFLICT` is the interesting case: the dialog reloads + * the newer record and offers to overwrite it, which it cannot do from a + * sentence. + */ + conflict?: ContentConflict; error?: string; + /** Why a schedule was refused, when the API said. */ + rejection?: ContentScheduleRejection; /** Lets the UI tell a restricted delete (409) from a generic failure. */ status?: number; + /** `CONTENT_REVISION_NOT_RESTORABLE`, naming the fields that no longer fit. */ + unprocessable?: ContentUnprocessable; } +/** Reads whatever structured error the API sent, if any. */ +const failure = (result: { + error?: string; + status: number; +}): MutationResult => ({ + conflict: parseContentConflict(result.error) ?? undefined, + error: result.error ?? "", + rejection: parseContentScheduleRejection(result.error) ?? undefined, + status: result.status, + unprocessable: parseContentUnprocessable(result.error) ?? undefined, +}); + const resolve = (contentTypeId: string) => { const entry = findFrontendContentType(contentTypeId); if (!entry) { @@ -153,9 +194,7 @@ export const createContentAction = async ( schema: zodRow, }); - if (result.status !== 201) { - return { error: result.error ?? "", status: result.status }; - } + if (result.status !== 201) return failure(result); revalidatePath(CONTENT_PAGE_PATH, "page"); // A new row starts as a draft, so this normally invalidates nothing at all - @@ -169,6 +208,11 @@ export const editContentAction = async ( contentTypeId: string, id: number, values: Record, + /** + * The version the editor started from. Required by an editorial content type + * and ignored by every other one, so the form can pass it unconditionally. + */ + expectedVersion?: number, ): Promise => { const { definition, pluginId } = resolve(contentTypeId); @@ -176,7 +220,7 @@ export const editContentAction = async ( const before = await readRow(definition, pluginId, id); const result = await contentApiFetch({ - body: values, + body: definition.editorial.enabled ? { expectedVersion, values } : values, definition, method: "put", path: `/${id}`, @@ -184,12 +228,283 @@ export const editContentAction = async ( schema: zodRow, }); - if (result.status !== 200) { + if (result.status !== 200) return failure(result); + + revalidatePath(CONTENT_PAGE_PATH, "page"); + invalidate(definition, id, before, result.data); + + return {}; +}; + +/** + * Re-reads one record, for the conflict banner. + * + * Deliberately not a full page refresh: the dialog is still open with the + * editor's unsaved values in it, and `router.refresh()` would remount the form + * and throw them away - which is the one thing the conflict flow must not do. + */ +export const reloadContentRowAction = async ( + contentTypeId: string, + id: number, +): Promise<{ error?: string; row?: ContentRow }> => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}`, + pluginId, + schema: zodRow, + }); + + if (result.status !== 200) return { error: result.error ?? "" }; + + return { row: result.data }; +}; + +const zodRevisionList = z.object({ + edges: z.array(z.object({ id: z.number() }).loose()), + pageInfo: z.object({ + endCursor: z.number().nullable(), + hasNextPage: z.boolean(), + }), +}); + +export interface ContentRevisionPageResult { + edges: ContentRevisionMeta[]; + error?: string; + pageInfo: { endCursor: null | number; hasNextPage: boolean }; +} + +/** + * One page of history. Metadata only - snapshots load one at a time. + * + * The cursor is the last **version** on the previous page and the route is + * exclusive on it, so pages append cleanly and never repeat their boundary row. + */ +export const listContentRevisionsAction = async ( + contentTypeId: string, + id: number, + cursor?: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}/revisions`, + pluginId, + query: cursor === undefined ? undefined : { cursor: String(cursor) }, + schema: zodRevisionList, + }); + + const empty = { endCursor: null, hasNextPage: false }; + + if (result.status !== 200 || !result.data) { + return { edges: [], error: result.error ?? "", pageInfo: empty }; + } + + return { + edges: result.data.edges as unknown as ContentRevisionMeta[], + pageInfo: result.data.pageInfo, + }; +}; + +export const getContentRevisionAction = async ( + contentTypeId: string, + id: number, + revisionId: number, +): Promise<{ error?: string; revision?: ContentRevisionDetail }> => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}/revisions/${revisionId}`, + pluginId, + schema: z.object({ id: z.number() }).loose(), + }); + + if (result.status !== 200) return { error: result.error ?? "" }; + + return { revision: result.data as unknown as ContentRevisionDetail }; +}; + +/** + * Restores one revision, and reports the version the record now holds. + * + * The version comes back because the history dialog stays open afterwards: its + * next restore needs the *new* precondition, and reusing the one it opened with + * would fail with a conflict against the restore it just performed. + */ +export const restoreContentRevisionAction = async ( + contentTypeId: string, + id: number, + revisionId: number, + expectedVersion: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + // Same as an edit: the old slug has to be known before the write, or a + // restore that moves the URL leaves the previous one resolving. + const before = await readRow(definition, pluginId, id); + + const result = await contentApiFetch({ + body: { expectedVersion }, + definition, + method: "post", + path: `/${id}/revisions/${revisionId}/restore`, + pluginId, + schema: z.object({ changed: z.boolean(), row: zodRow }), + }); + + if (result.status !== 200) return failure(result); + + revalidatePath(CONTENT_PAGE_PATH, "page"); + // A restore never moves `status`, so visibility is unchanged - but the slug + // may have, and `invalidate` compares both rows to work out which. + invalidate(definition, id, before, result.data?.row); + + const version = result.data?.row.version; + + return { version: typeof version === "number" ? version : undefined }; +}; + +export interface ContentPreviewLink { + expiresAt: string; + revisionId: number; + url: string; + version: number; +} + +/** + * Mints a preview link, on the click and not before. + * + * Nothing here is cached or revalidated: no row changed, and a token is a + * short-lived bearer credential for an unpublished record. Handing one to every + * row of the table "just in case" would mean a page of live credentials sitting + * in a browser, most of them never used. + * + * The `url` comes back from the server rather than being assembled here, + * because only the definition knows whether the install has a preview page + * (`preview.pathTemplate`) or should link at the JSON endpoint. + */ +export const createContentPreviewAction = async ( + contentTypeId: string, + id: number, +): Promise<{ + error?: string; + preview?: ContentPreviewLink; + status?: number; +}> => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "post", + path: `/${id}/preview`, + pluginId, + schema: z.object({ + expiresAt: z.string(), + revisionId: z.number(), + token: z.string(), + url: z.string(), + version: z.number(), + }), + }); + + if (result.status !== 200 || !result.data) { return { error: result.error ?? "", status: result.status }; } + const { expiresAt, revisionId, url, version } = result.data; + + // The token itself is deliberately not returned: it is already inside `url`, + // and a second copy is a second thing to leak. + return { preview: { expiresAt, revisionId, url, version } }; +}; + +export const listContentSchedulesAction = async ( + contentTypeId: string, + id: number, +): Promise<{ + edges: ContentSchedule[]; + error?: string; + hasCronAdapter: boolean; +}> => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}/schedules`, + pluginId, + schema: z.object({ + edges: z.array(z.object({ id: z.number() }).loose()), + hasCronAdapter: z.boolean(), + }), + }); + + if (result.status !== 200) { + return { edges: [], error: result.error ?? "", hasCronAdapter: true }; + } + + return { + edges: (result.data?.edges ?? []) as unknown as ContentSchedule[], + hasCronAdapter: result.data?.hasCronAdapter ?? true, + }; +}; + +/** + * Books a publication or an unpublication for later. + * + * Nothing public changes yet, so no cache tag is expired - only the admin table + * is refreshed, because it now shows a pending badge. The transition itself + * invalidates the cache when it fires, over the + * [revalidation bridge](/docs/dev/content-engine/scheduling). + */ +export const scheduleContentAction = async ( + contentTypeId: string, + id: number, + action: ContentScheduleAction, + scheduledFor: string, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + body: { action, scheduledFor }, + definition, + method: "post", + path: `/${id}/schedule`, + pluginId, + schema: z.object({ id: z.number() }), + }); + + if (result.status !== 200) return failure(result); + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; + +export const cancelContentScheduleAction = async ( + contentTypeId: string, + id: number, + scheduleId: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "post", + path: `/${id}/schedule/${scheduleId}/cancel`, + pluginId, + schema: z.object({ cancelled: z.boolean() }), + }); + + if (result.status !== 200) return failure(result); + revalidatePath(CONTENT_PAGE_PATH, "page"); - invalidate(definition, id, before, result.data); return {}; }; @@ -197,10 +512,20 @@ export const editContentAction = async ( export const deleteContentAction = async ( contentTypeId: string, id: number, + /** + * The version the row showed when the person clicked delete. Required by an + * editorial content type and ignored by every other one, so the table can + * pass it unconditionally. + */ + expectedVersion?: number, ): Promise => { const { definition, pluginId } = resolve(contentTypeId); const result = await contentApiFetch({ + // A body on a `DELETE`, matching the route: the precondition travels with + // the request that acts on it rather than in a query string that ends up in + // access logs. + body: definition.editorial.enabled ? { expectedVersion } : undefined, definition, method: "delete", path: `/${id}`, @@ -208,9 +533,7 @@ export const deleteContentAction = async ( schema: zodRow, }); - if (result.status !== 200) { - return { error: result.error ?? "", status: result.status }; - } + if (result.status !== 200) return failure(result); revalidatePath(CONTENT_PAGE_PATH, "page"); @@ -257,9 +580,7 @@ const publicationAction = async ( schema: zodPublicationResult, }); - if (result.status !== 200) { - return { error: result.error ?? "", status: result.status }; - } + if (result.status !== 200) return failure(result); revalidatePath(CONTENT_PAGE_PATH, "page"); 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 3b682de32..a55801b2a 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 @@ -5,6 +5,7 @@ import type { AnyContentTypeDefinition } from "@/content/types"; import { testCategoryContentType, + testEditorialPostContentType, testPostContentType, } from "@/tests/content-fixtures"; @@ -14,8 +15,8 @@ interface CacheCall { } const cacheCalls: CacheCall[] = []; -const fetches: { method: string; path?: string }[] = []; -let responses: { data?: unknown; status: number }[] = []; +const fetches: { body?: unknown; method: string; path?: string }[] = []; +let responses: { data?: unknown; error?: string; status: number }[] = []; let definition: AnyContentTypeDefinition = testPostContentType; // The real `revalidate.server` runs: what this suite is about is which Next @@ -43,14 +44,16 @@ vi.mock("@/content/admin/config", () => ({ vi.mock("@/content/admin/fetch.server", () => ({ contentApiFetch: async ({ + body, method, path, }: { + body?: unknown; method: string; path?: string; }) => { await Promise.resolve(); - fetches.push({ method, path }); + fetches.push({ body, method, path }); return responses.shift() ?? { status: 500 }; }, @@ -60,7 +63,10 @@ const { createContentAction, deleteContentAction, editContentAction, + listContentRevisionsAction, publishContentAction, + reloadContentRowAction, + restoreContentRevisionAction, unpublishContentAction, } = await import("./mutation-api.server"); @@ -69,6 +75,9 @@ 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}`; +/** The editorial fixture is a different content type, so different tags. */ +const editorialSlugTag = (slug: string) => + `content:test.editorial:slug:${slug}`; const tags = () => cacheCalls.map(call => call.tag); /** Which Next cache API was used - `updateTag` is the immediate one. */ @@ -331,3 +340,255 @@ describe("failures", () => { expect(cacheCalls).toEqual([]); }); }); + +describe("editorial", () => { + beforeEach(() => { + definition = testEditorialPostContentType; + }); + + const editorialRow = { + id: 7, + publishedAt: past, + slug: "hello", + status: "published", + title: "Hello", + version: 4, + }; + + it("wraps the values in an envelope with the expected version", async () => { + responses = [ + { data: editorialRow, status: 200 }, + { data: { ...editorialRow, version: 5 }, status: 200 }, + ]; + + await editContentAction("test.editorial", 7, { title: "Changed" }, 4); + + const put = fetches.find(entry => entry.method === "put"); + expect(put?.body).toEqual({ + expectedVersion: 4, + values: { title: "Changed" }, + }); + }); + + it("sends a bare body for a content type without the workflow", async () => { + definition = testPostContentType; + responses = [ + { data: editorialRow, status: 200 }, + { data: editorialRow, status: 200 }, + ]; + + await editContentAction("test.post", 7, { title: "Changed" }, 4); + + const put = fetches.find(entry => entry.method === "put"); + expect(put?.body).toEqual({ title: "Changed" }); + }); + + it("surfaces a version conflict as structured data", async () => { + responses = [ + { data: editorialRow, status: 200 }, + { + error: JSON.stringify({ + code: "CONTENT_VERSION_CONFLICT", + contentTypeId: "test.editorial", + currentVersion: 9, + expectedVersion: 4, + itemId: 7, + }), + status: 409, + }, + ]; + + const result = await editContentAction( + "test.editorial", + 7, + { title: "Changed" }, + 4, + ); + + expect(result.conflict).toEqual({ + code: "CONTENT_VERSION_CONFLICT", + contentTypeId: "test.editorial", + currentVersion: 9, + expectedVersion: 4, + itemId: 7, + }); + // A refused write changed nothing, so nothing public went stale. + expect(cacheCalls).toEqual([]); + }); + + it("leaves `conflict` unset for a plain-text failure", async () => { + responses = [ + { data: editorialRow, status: 200 }, + { error: "A record with these values already exists.", status: 409 }, + ]; + + const result = await editContentAction( + "test.editorial", + 7, + { title: "Changed" }, + 4, + ); + + expect(result.conflict).toBeUndefined(); + expect(result.status).toBe(409); + }); + + it("reads one record back without touching the cache", async () => { + responses = [{ data: { ...editorialRow, version: 9 }, status: 200 }]; + + const result = await reloadContentRowAction("test.editorial", 7); + + expect(result.row?.version).toBe(9); + // The dialog is still open with unsaved values; a refresh would discard + // them, so the reload must not trigger one. + expect(cacheCalls).toEqual([]); + }); + + it("lists revisions", async () => { + responses = [ + { + data: { + edges: [{ id: 20, version: 5 }], + pageInfo: { endCursor: 5, hasNextPage: false }, + }, + status: 200, + }, + ]; + + const result = await listContentRevisionsAction("test.editorial", 7); + + expect(result.edges).toHaveLength(1); + expect(fetches[0].path).toBe("/7/revisions"); + }); + + it("carries the page info back so the dialog can offer another page", async () => { + responses = [ + { + data: { + edges: [{ id: 20, version: 5 }], + pageInfo: { endCursor: 5, hasNextPage: true }, + }, + status: 200, + }, + ]; + + const result = await listContentRevisionsAction("test.editorial", 7); + + expect(result.pageInfo).toEqual({ endCursor: 5, hasNextPage: true }); + }); + + it("sends the cursor as a query parameter", async () => { + responses = [ + { + data: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, + status: 200, + }, + ]; + + await listContentRevisionsAction("test.editorial", 7, 36); + + expect(fetches[0]).toMatchObject({ path: "/7/revisions" }); + }); + + it("reports the new version after a restore", async () => { + // The dialog stays open, so its next restore needs the version the record + // holds now - reusing the one it opened with would conflict with the + // restore it just performed. + responses = [ + { data: editorialRow, status: 200 }, + { + data: { changed: true, row: { ...editorialRow, version: 5 } }, + status: 200, + }, + ]; + + const result = await restoreContentRevisionAction( + "test.editorial", + 7, + 3, + 4, + ); + + expect(result.version).toBe(5); + }); + + describe("delete", () => { + it("sends the version the row was showing", async () => { + responses = [{ data: editorialRow, status: 200 }]; + + await deleteContentAction("test.editorial", 7, 4); + + expect(fetches[0]).toMatchObject({ + body: { expectedVersion: 4 }, + method: "delete", + path: "/7", + }); + }); + + it("sends no body for a content type without editorial", async () => { + // The Stage 1-3 contract. A precondition on a route that never had one + // would break every existing client. + definition = testPostContentType; + responses = [{ data: { id: 7, publishedAt: null }, status: 200 }]; + + await deleteContentAction("test.post", 7, 4); + + expect(fetches[0].body).toBeUndefined(); + }); + + it("hands the version conflict back to the caller to explain", async () => { + responses = [ + { + error: JSON.stringify({ + code: "CONTENT_VERSION_CONFLICT", + contentTypeId: "test.editorial", + currentVersion: 5, + expectedVersion: 4, + itemId: 7, + }), + status: 409, + }, + ]; + + const result = await deleteContentAction("test.editorial", 7, 4); + + expect(result.conflict?.code).toBe("CONTENT_VERSION_CONFLICT"); + // Nothing was deleted, so nothing public went stale. + expect(cacheCalls).toEqual([]); + }); + }); + + it("expires the old and new slug when a restore moves the URL", async () => { + responses = [ + { data: editorialRow, status: 200 }, + { + data: { + changed: true, + row: { ...editorialRow, slug: "moved", version: 5 }, + }, + status: 200, + }, + ]; + + await restoreContentRevisionAction("test.editorial", 7, 3, 4); + + expect(tags()).toContain(editorialSlugTag("hello")); + expect(tags()).toContain(editorialSlugTag("moved")); + // A moved URL must not serve its old response even once. + expect(mode()).toEqual(["updateTag"]); + }); + + it("keeps the cache warm when a restore leaves the URL alone", async () => { + responses = [ + { data: editorialRow, status: 200 }, + { + data: { changed: true, row: { ...editorialRow, version: 5 } }, + status: 200, + }, + ]; + + await restoreContentRevisionAction("test.editorial", 7, 3, 4); + + expect(mode()).toEqual(["revalidateTag"]); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx new file mode 100644 index 000000000..1672aef88 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; + +import { DateFormat } from "@/components/date-format"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +import type { ContentPreviewLink } from "./mutation-api.server"; + +import { contentErrorKey } from "../lib/mutation-feedback"; +import { createContentPreviewAction } from "./mutation-api.server"; + +/** How long the "copied" tick stays before the icon flips back. */ +const COPIED_FEEDBACK_MS = 2000; + +const CopyButton = ({ label, url }: { label: string; url: string }) => { + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => { + if (!copied) return; + + const timer = setTimeout(() => { + setCopied(false); + }, COPIED_FEEDBACK_MS); + + return () => { + clearTimeout(timer); + }; + }, [copied]); + + return ( + + ); +}; + +/** + * The preview row action. + * + * Present only for a content type with `editorial.preview`, and absent rather + * than disabled for anything else - a greyed-out button invites someone to + * work out how to enable it, and this one cannot be enabled from the UI. + * + * The token is minted **when the popover opens**, never earlier. A table of 25 + * rows must not be 25 live bearer credentials for unpublished records sitting + * in a browser, and most of them would never be used. + */ +export const PreviewContentAction = ({ + contentTypeId, + id, + permissionModule, + pluginId, + title, +}: { + contentTypeId: string; + id: number; + permissionModule: string; + pluginId: string; + title: string; +}) => { + const t = useTranslations("core.content.preview"); + const tErrors = useTranslations("core.global.errors"); + const tContentErrors = useTranslations("core.content.errors"); + const [preview, setPreview] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + // Reading is enough: a preview shows exactly what the public route would, so + // anyone allowed to open the record in the AdminCP may already see it. + const canView = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + }); + + if (!canView) return null; + + const label = t("title"); + + return ( + { + if (!open) { + // Dropped on close so the next open mints a fresh link rather than + // showing one that may already have expired in the meantime. + setPreview(null); + + return; + } + + setLoading(true); + void createContentPreviewAction(contentTypeId, id) + .then(result => { + if (result.preview) { + setPreview(result.preview); + + return; + } + + // 503 is the one failure with a fix the person reading it can + // apply, so it says what to do rather than "something went wrong". + if (result.status === 503) { + toast.error(tErrors("title"), { + description: t("unavailable"), + }); + + return; + } + + const errorKey = contentErrorKey(result.status); + toast.error(tErrors("title"), { + description: errorKey + ? tContentErrors(errorKey) + : tErrors("internal_server_error"), + }); + }) + .finally(() => { + setLoading(false); + }); + }} + > + + + + } + /> + + + {label} +

    + {t("desc", { title })} +

    + + {loading || !preview ? ( +

    {t("loading")}

    + ) : ( + <> +
    + + +
    + +
    + + {t.rich("expires", { + when: () => , + })} + + +
    + + {/* `0` means the record predates its content type opting into + editorial, so there is no snapshot to freeze and the link reads + the live row. Worth saying out loud - "preview" otherwise + promises something this one link cannot deliver. */} + {preview.revisionId === 0 ? ( +

    + {t("live")} +

    + ) : null} + +

    + {t("warning")} +

    + + )} +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/schedule-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/schedule-action.tsx new file mode 100644 index 000000000..ac9841cd2 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/schedule-action.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { CalendarClockIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +// The panel carries a form and the whole schedule list, so it loads with the +// dialog rather than with the table - the same treatment the edit form gets. +const SchedulePanel = dynamic(async () => + import("./schedule/schedule-panel").then(mod => ({ + default: mod.SchedulePanel, + })), +); + +/** + * The scheduling row action. + * + * Gated by `can_publish`, not `can_edit`. Booking a publication *is* + * publishing, just later - a role trusted to write drafts is not automatically + * trusted to put one on the internet at 9am on Monday, and the route says the + * same thing whether or not this button was rendered. + */ +export const ScheduleContentAction = ({ + contentTypeId, + id, + permissionModule, + pluginId, + singular, + title, +}: { + contentTypeId: string; + id: number; + permissionModule: string; + pluginId: string; + singular: string; + title: string; +}) => { + const t = useTranslations("core.content.schedule"); + const canPublish = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.publish, + plugin: pluginId, + }); + + if (!canPublish) return null; + + const label = t("title", { name: singular }); + + return ( + + + + + + + } + /> + } + /> + + + + {label} + {/* `t.rich`, because the message names the record with a + `` tag - the same shape delete, publish and restore + use. Passing a plain string for a tag is a formatting error + at render time, not a compile one. */} + <DialogDescription> + {t.rich("desc", { + title: () => ( + <span className="text-foreground font-bold">{title}</span> + ), + })} + </DialogDescription> + </DialogHeader> + + <React.Suspense fallback={<Loader />}> + <SchedulePanel + contentTypeId={contentTypeId} + id={id} + singular={singular} + title={title} + /> + </React.Suspense> + </DialogContent> + </Dialog> + + <TooltipContent>{label}</TooltipContent> + </Tooltip> + </TooltipProvider> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx new file mode 100644 index 000000000..e92a1aeda --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx @@ -0,0 +1,323 @@ +// No "use client": reached only from `schedule-action`, which is a client entry. +import { + CalendarClockIcon, + CheckIcon, + TriangleAlertIcon, + XIcon, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { ContentSchedule } from "@/content/schedules"; + +import { DateFormat } from "@/components/date-format"; +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormDateTime } from "@/components/form/fields/date-time"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Loader } from "@/components/ui/loader"; +import { contentScheduleTimingError } from "@/content/schedules"; + +import { contentErrorKey } from "../../lib/mutation-feedback"; +import { + cancelContentScheduleAction, + listContentSchedulesAction, + scheduleContentAction, +} from "../mutation-api.server"; + +const formSchema = z.object({ + action: z.enum(["publish", "unpublish"]), + scheduledFor: z.iso.datetime(), +}); + +/** One row in the list of what is booked and what already ran. */ +const ScheduleRow = ({ + now, + onCancel, + schedule, +}: { + /** Passed in rather than read here: render must not call the clock. */ + now: number; + onCancel: (scheduleId: number) => Promise<void>; + schedule: ContentSchedule; +}) => { + const t = useTranslations("core.content.schedule"); + const [cancelling, setCancelling] = React.useState(false); + const pending = schedule.status === "pending"; + const overdue = pending && new Date(schedule.scheduledFor).getTime() < now; + + return ( + <li className="flex flex-wrap items-center gap-2 border-b py-2 last:border-b-0"> + <Badge variant={pending ? "default" : "secondary"}> + {t(`actions.${schedule.action}`)} + </Badge> + + <span className="text-sm"> + <DateFormat date={schedule.scheduledFor} showFullDate /> + </span> + + <span className="text-muted-foreground text-xs"> + {t(`status.${schedule.status}`)} + {schedule.actorName ? ` · ${schedule.actorName}` : null} + </span> + + {overdue ? ( + <span className="text-xs text-amber-600 dark:text-amber-400"> + {t("overdue")} + </span> + ) : null} + + {schedule.lastError ? ( + <span className="text-destructive w-full text-xs wrap-break-word"> + {schedule.lastError} + </span> + ) : null} + + {/* A different thing from `lastError`, and it reads as one: the record + really did publish, and what is still being retried is the event, the + search write and the cache invalidation. */} + {schedule.effectsError ? ( + <span className="w-full text-xs wrap-break-word text-amber-600 dark:text-amber-400"> + {t("effects_failed")} + </span> + ) : null} + + {pending ? ( + <Button + aria-label={t("cancel")} + className="ml-auto" + disabled={cancelling} + isLoading={cancelling} + onClick={() => { + setCancelling(true); + void onCancel(schedule.id).finally(() => { + setCancelling(false); + }); + }} + size="sm" + type="button" + variant="ghost" + > + <XIcon className="size-4" /> + {t("cancel")} + </Button> + ) : ( + <CheckIcon + aria-hidden + className="text-muted-foreground ml-auto size-4" + /> + )} + </li> + ); +}; + +/** + * Everything scheduled for one record, and the form that adds another. + * + * Lazy-loaded like the edit form and the history dialog, and for the same + * reason: it is only ever in the tree while its own dialog is open. + */ +export const SchedulePanel = ({ + contentTypeId, + id, + singular, + title, +}: { + contentTypeId: string; + id: number; + singular: string; + title: string; +}) => { + const t = useTranslations("core.content.schedule"); + const tErrors = useTranslations("core.global.errors"); + const tContentErrors = useTranslations("core.content.errors"); + const [state, setState] = React.useState<null | { + edges: ContentSchedule[]; + hasCronAdapter: boolean; + /** When the list was fetched, so "overdue" is decided outside render. */ + loadedAt: number; + }>(null); + + const reload = React.useCallback(async () => { + const result = await listContentSchedulesAction(contentTypeId, id); + + setState({ + edges: result.edges, + hasCronAdapter: result.hasCronAdapter, + + loadedAt: Date.now(), + }); + }, [contentTypeId, id]); + + React.useEffect(() => { + let active = true; + + void listContentSchedulesAction(contentTypeId, id).then(result => { + if (!active) return; + + setState({ + edges: result.edges, + hasCronAdapter: result.hasCronAdapter, + + loadedAt: Date.now(), + }); + }); + + return () => { + active = false; + }; + }, [contentTypeId, id]); + + if (!state) return <Loader />; + + const pending = state.edges.filter(entry => entry.status === "pending"); + + const onSubmit: AutoFormOnSubmit<typeof formSchema> = async values => { + // The same pure rule the server enforces, run before the round trip so an + // impossible date is refused where the editor is looking. The server stays + // the authority; this is only faster. + const timing = contentScheduleTimingError({ + action: values.action, + now: new Date(), + pending, + scheduledFor: new Date(values.scheduledFor), + }); + + if (timing) { + toast.error(tErrors("title"), { + description: t( + timing === "CONTENT_SCHEDULE_ORDER" + ? "errors.order" + : "errors.in_past", + ), + }); + + return; + } + + const mutation = await scheduleContentAction( + contentTypeId, + id, + values.action, + new Date(values.scheduledFor).toISOString(), + ); + + if (mutation.error !== undefined) { + // A refused schedule has its own words - "that time has passed" is + // actionable, "something went wrong" is not. + const key = contentErrorKey(mutation.status); + const description = mutation.rejection + ? t( + mutation.rejection.code === "CONTENT_SCHEDULE_ORDER" + ? "errors.order" + : mutation.rejection.code === "CONTENT_SCHEDULE_IN_PAST" + ? "errors.in_past" + : "errors.unsupported", + ) + : key + ? tContentErrors(key) + : tErrors("internal_server_error"); + + toast.error(tErrors("title"), { description }); + + return; + } + + toast.success(t("success", { name: singular }), { description: title }); + await reload(); + }; + + return ( + <div className="flex flex-col gap-4"> + {!state.hasCronAdapter ? ( + <Alert variant="warning"> + <TriangleAlertIcon /> + <AlertTitle>{t("no_cron.title")}</AlertTitle> + <AlertDescription>{t("no_cron.desc")}</AlertDescription> + </Alert> + ) : null} + + {state.edges.length > 0 ? ( + <ul className="flex flex-col"> + {state.edges.map(schedule => ( + <ScheduleRow + key={schedule.id} + now={state.loadedAt} + onCancel={async scheduleId => { + const mutation = await cancelContentScheduleAction( + contentTypeId, + id, + scheduleId, + ); + + if (mutation.error !== undefined) { + const key = contentErrorKey(mutation.status); + toast.error(tErrors("title"), { + description: key + ? tContentErrors(key) + : tErrors("internal_server_error"), + }); + + return; + } + + toast.success(t("cancelled"), { description: title }); + await reload(); + }} + schedule={schedule} + /> + ))} + </ul> + ) : ( + <p className="text-muted-foreground text-sm">{t("empty")}</p> + )} + + <AutoForm + fields={[ + { + id: "action", + + component: props => ( + <AutoFormSelect + label={t("field.action")} + // The values come from the schema's enum; this only translates + // them for display. + labels={[ + { label: t("actions.publish"), value: "publish" }, + { label: t("actions.unpublish"), value: "unpublish" }, + ]} + {...props} + /> + ), + }, + { + id: "scheduledFor", + + component: props => ( + <AutoFormDateTime + description={t("field.when_desc", { + zone: Intl.DateTimeFormat().resolvedOptions().timeZone, + })} + label={t("field.when")} + {...props} + /> + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + /> + + <p className="text-muted-foreground flex items-start gap-2 text-xs leading-relaxed"> + <CalendarClockIcon aria-hidden className="mt-0.5 size-3.5 shrink-0" /> + {t("precision")} + </p> + </div> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.test.ts b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.test.ts index b04c73932..f664ac831 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.test.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.test.ts @@ -23,4 +23,54 @@ describe("contentErrorKey", () => { expect(contentErrorKey(502)).toBeNull(); expect(contentErrorKey(undefined)).toBeNull(); }); + + describe("structured editorial errors", () => { + it("separates a lost update from a taken value", () => { + // Both are 409, and they need different words *and* different buttons - + // which is the reason the code exists at all. + const version = contentErrorKey(409, { + conflict: { + code: "CONTENT_VERSION_CONFLICT", + contentTypeId: "test.editorial", + currentVersion: 9, + expectedVersion: 4, + itemId: 7, + }, + }); + const unique = contentErrorKey(409, { + conflict: { + code: "CONTENT_UNIQUE_CONFLICT", + contentTypeId: "test.editorial", + itemId: 7, + }, + }); + + expect(version).toBe("version_conflict"); + expect(unique).toBe("unique_conflict"); + expect(version).not.toBe(unique); + }); + + it("maps an unrestorable revision", () => { + expect( + contentErrorKey(422, { + unprocessable: { + code: "CONTENT_REVISION_NOT_RESTORABLE", + contentTypeId: "test.editorial", + fields: ["title"], + revisionId: 3, + }, + }), + ).toBe("not_restorable"); + }); + + it("still handles a 422 with no body", () => { + expect(contentErrorKey(422)).toBe("not_restorable"); + }); + + it("leaves a plain-text 409 on the old message", () => { + // A Stage 1-3 route sends no JSON body, and its 409 still means "still + // referenced by other content". + expect(contentErrorKey(409, {})).toBe("conflict"); + }); + }); }); diff --git a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts index d14c0a946..c019f1bca 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts @@ -1,6 +1,17 @@ +import type { + ContentConflict, + ContentUnprocessable, +} from "@/content/conflicts"; + /** Keys under `core.content.errors` that a mutation status maps onto. */ export type ContentErrorKey = - "conflict" | "forbidden" | "not_found" | "validation"; + | "conflict" + | "forbidden" + | "not_found" + | "not_restorable" + | "unique_conflict" + | "validation" + | "version_conflict"; /** * Turns a generated route's status into something a person can act on. @@ -10,10 +21,27 @@ export type ContentErrorKey = * "this row is still referenced" from "the server fell over" without ever * echoing what Postgres said. Anything unrecognised falls through to `null`, * which the caller renders as the global server-error message. + * + * An editorial route sends a JSON body with a `code` on the two statuses a + * client has to branch on, and that wins when present: 409 alone cannot + * distinguish "someone saved first" from "that value is taken", and the two + * need different words *and* different buttons. */ export const contentErrorKey = ( status: number | undefined, + structured?: { + conflict?: ContentConflict; + unprocessable?: ContentUnprocessable; + }, ): ContentErrorKey | null => { + if (structured?.conflict) { + return structured.conflict.code === "CONTENT_VERSION_CONFLICT" + ? "version_conflict" + : "unique_conflict"; + } + + if (structured?.unprocessable) return "not_restorable"; + switch (status) { case 400: return "validation"; @@ -23,6 +51,8 @@ export const contentErrorKey = ( return "not_found"; case 409: return "conflict"; + case 422: + return "not_restorable"; default: return null; } 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 index a4bb15ce4..896eaa245 100644 --- 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 @@ -6,6 +6,7 @@ import type { AnyContentTypeDefinition } from "@/content/types"; import { testArticleContentType, + testEditorialPostContentType, testPostContentType, } from "@/tests/content-fixtures"; @@ -39,6 +40,7 @@ vi.mock("@/content/admin/fetch.server", () => ({ })); const { ContentTableView } = await import("./content-table-view"); +const { DeleteContentAction } = await import("../actions/delete-action"); /** * The `order` prop the view hands `DataTable`. @@ -64,6 +66,60 @@ const orderProp = async (definition: AnyContentTypeDefinition) => { return element.props.order; }; +/** + * The props the actions cell hands `DeleteContentAction` for one row. + * + * The cell is a plain function returning a fragment, so it can be called and + * walked without a DOM - which is the cheapest way to assert what a row action + * is actually given. + */ +const deleteProps = async ( + definition: AnyContentTypeDefinition, + row: Record<string, unknown>, +) => { + const element = (await ContentTableView({ + columnSpecs: [], + entry: { + definition, + pluginId: "@vitnode/example", + registration: {}, + } as never, + formSpec: {} as never, + searchParams: {}, + })) as ReactElement<{ + columns: { + cell?: (context: { row: Record<string, unknown> }) => ReactElement<{ + children: ReactElement<Record<string, unknown>>[]; + }>; + id?: string; + }[]; + }>; + + const actions = element.props.columns.find(column => column.id === "actions"); + const rendered = actions?.cell?.({ row }); + + return rendered?.props.children.find( + child => child?.type === DeleteContentAction, + )?.props; +}; + +describe("the delete row action", () => { + it("is handed the version the row is showing", async () => { + // The precondition the editorial delete route requires. Taken from the row + // in front of the person, so a stale table cannot remove a newer record. + expect( + await deleteProps(testEditorialPostContentType, { id: 7, version: 4 }), + ).toMatchObject({ id: 7, version: 4 }); + }); + + it("is handed no version without editorial", async () => { + // `test.post` has no `version` column and its delete route takes no body. + expect(await deleteProps(testPostContentType, { id: 7 })).toMatchObject({ + version: undefined, + }); + }); +}); + describe("sortable columns", () => { it("offers every column the generated route accepts", async () => { // The route's `orderBy` enum is `orderableColumns(definition)`. Passing 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 55fcfcf9c..3625e9d2f 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 @@ -14,7 +14,10 @@ import type { ContentRowData } from "./cells"; import { DeleteContentAction } from "../actions/delete-action"; import { EditContentAction } from "../actions/edit-action"; +import { HistoryContentAction } from "../actions/history-action"; +import { PreviewContentAction } from "../actions/preview-action"; import { PublishContentAction } from "../actions/publish-action"; +import { ScheduleContentAction } from "../actions/schedule-action"; import { ContentCell } from "./cells"; const zodList = z.object({ @@ -103,8 +106,17 @@ export const ContentTableView = async ({ id: "actions", header: "", align: "right", - // Room for the third button publication adds. - className: definition.publication.enabled ? "w-28" : "w-20", + // One column per button: publication adds a third, editorial a fourth, + // preview a fifth and scheduling a sixth. + className: [ + "w-20", + definition.publication.enabled ? "w-28" : "", + definition.editorial.enabled ? "w-36" : "", + definition.editorial.preview.enabled ? "w-44" : "", + definition.editorial.scheduling.enabled ? "w-52" : "", + ] + .filter(Boolean) + .at(-1), cell: ({ row }) => { const title = titleField && typeof row[titleField] === "string" @@ -113,6 +125,39 @@ export const ContentTableView = async ({ return ( <> + {definition.editorial.preview.enabled ? ( + <PreviewContentAction + contentTypeId={definition.id} + id={row.id} + permissionModule={definition.permissionModule} + pluginId={pluginId} + title={title} + /> + ) : null} + {definition.editorial.scheduling.enabled ? ( + <ScheduleContentAction + contentTypeId={definition.id} + id={row.id} + permissionModule={definition.permissionModule} + pluginId={pluginId} + singular={definition.admin.label.singular} + title={title} + /> + ) : null} + {definition.editorial.enabled ? ( + <HistoryContentAction + contentTypeId={definition.id} + currentVersion={ + typeof row.version === "number" ? row.version : 1 + } + id={row.id} + permissionModule={definition.permissionModule} + pluginId={pluginId} + singular={definition.admin.label.singular} + spec={formSpec} + title={title} + /> + ) : null} {definition.publication.enabled ? ( <PublishContentAction contentTypeId={definition.id} @@ -145,6 +190,14 @@ export const ContentTableView = async ({ pluginId={pluginId} singular={definition.admin.label.singular} title={title} + // The precondition the delete route checks. Taken from the row + // the person is actually looking at, so a stale table cannot + // remove a newer record. + version={ + definition.editorial.enabled && typeof row.version === "number" + ? row.version + : undefined + } /> </> ); diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-view.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-view.tsx index d2ac8e29b..6f19a7aed 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-view.tsx @@ -1,6 +1,7 @@ import { ClockIcon, DatabaseIcon, + EyeIcon, HardDriveIcon, ListTodoIcon, MailIcon, @@ -23,6 +24,7 @@ import { TestStorageAction } from "./test-storage/test-storage"; const DOCS_URLS = { ai: "https://vitnode.com/docs/dev/ai", captcha: "https://vitnode.com/docs/dev/captcha", + contentPreview: "https://vitnode.com/docs/dev/content-engine/preview", cron: "https://vitnode.com/docs/dev/cron", email: "https://vitnode.com/docs/dev/email", queue: "https://vitnode.com/docs/dev/advanced/queue", @@ -72,6 +74,15 @@ export const IntegrationsView = async () => { ? "warning" : "inactive"; + // An insecure secret is a warning rather than a failure: the routes work + // perfectly, and every link they mint is forgeable by anyone who has read the + // source - which is worse than a broken feature, so it must be visible. + const contentPreviewStatus: IntegrationStatus = !data.contentPreview.active + ? "inactive" + : data.contentPreview.secure + ? "active" + : "warning"; + // "Active" means a cron adapter is configured (an in-process scheduler runs // the jobs). A stale scheduler (no job ran in 6h) or an insecure secret are // warnings, not hard failures. @@ -185,6 +196,31 @@ export const IntegrationsView = async () => { title={t("cron.title")} /> + <IntegrationCard + description={t("content_preview.desc")} + href={DOCS_URLS.contentPreview} + Icon={EyeIcon} + meta={ + !data.contentPreview.active ? ( + <span>{t("content_preview.not_configured")}</span> + ) : !data.contentPreview.secure ? ( + <span className="text-amber-600 dark:text-amber-400"> + {t("content_preview.insecure")} + </span> + ) : ( + <span> + {t("content_preview.content_types", { + count: data.contentPreview.contentTypes, + })} + </span> + ) + } + readMoreLabel={t("read_more")} + status={contentPreviewStatus} + statusLabel={statusLabel(contentPreviewStatus)} + title={t("content_preview.title")} + /> + <IntegrationCard description={t("queue.desc")} href={DOCS_URLS.queue} @@ -238,6 +274,7 @@ export const IntegrationsViewSkeleton = () => ( "email", "storage", "cron", + "content_preview", "queue", "captcha", ].map(id => ( diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index e5ad476ed..cf55d1262 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -68,6 +68,17 @@ export interface VitNodeApiConfig { siteKey: string | undefined; type: "cloudflare_turnstile" | "recaptcha_v3"; }; + /** Content Engine settings that are deployment-shaped rather than per type. */ + content?: { + /** + * Web origins to notify when background work changes what is public. + * + * Defaults to `[NEXT_PUBLIC_WEB_URL]`, which is right for the usual one-web + * app install. Set it when one API serves several front ends: each origin + * owns its own Next cache, and each is posted independently. + */ + revalidateOrigins?: string[]; + }; cron?: CronAdapter; dbProvider: ReturnType<typeof drizzle>; email?: { diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 295c58e5e..b7fade25f 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -13,4 +13,12 @@ export const EXAMPLE_MIGRATIONS = [ "0022_add_example_content.sql", "0023_add_publication_to_example_articles.sql", "0024_add_example_article_slug.sql", + // Core, not `example_*`, but the editorial suites write revisions for an + // article - so the table has to exist before the column that needs it. + "0025_add_content_revisions.sql", + "0026_add_example_article_editorial.sql", + // Core again, for the same reason: the scheduling suites book a publication + // for an article, and the row has to have somewhere to go. + "0027_add_content_schedules.sql", + "0028_add_content_schedule_effects_error.sql", ]; diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index 69fab5094..ab6c9574d 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -2,19 +2,6 @@ import { defineContentType, field } from "@vitnode/core/content"; import { categoryContentType } from "./category"; -/** - * 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 - * permissions) and by `src/database/articles.ts` (the Drizzle table). - */ export const articleContentType = defineContentType({ id: "example.article", tableName: "example_articles", @@ -39,10 +26,6 @@ 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", @@ -54,13 +37,6 @@ export const articleContentType = defineContentType({ defaultOrder: "desc", }, - // Published articles are kept in the site-wide search index automatically: - // publishing adds the document, editing an indexed field or the slug rewrites - // it, unpublishing and deleting remove it. Drafts are never indexed. - // - // Every field named here is also in `publicApi.fields` - that is enforced by - // the types, not just by review. Naming `code` or `author` would not compile, - // which is what stops a private value surfacing in a result snippet. search: { enabled: true, titleField: "title", @@ -69,8 +45,13 @@ export const articleContentType = defineContentType({ pathTemplate: "/articles/{slug}", }, - // The generated columns are addressable here too. `(status, publishedAt)` is - // generated automatically; this one backs "newest drafts first". + editorial: { + enabled: true, + revisions: { retention: 20 }, + preview: { enabled: true, expiresInMinutes: 30 }, + scheduling: { enabled: true }, + }, + indexes: [{ on: ["status", "createdAt"] }], admin: { diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index d2ae33ea9..bb543a139 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -1,10 +1,15 @@ import type { ContentSearchOperation } from "@vitnode/core/content/server"; import type { Context } from "hono"; +import { executeContentSchedule } from "@vitnode/core/api/modules/content/helpers/execute-content-schedule"; +import { ContentVersionConflict } from "@vitnode/core/content"; import { + claimContentSchedule, createContentSearchIndexer, + settleContentSchedule, syncContentSearch, } from "@vitnode/core/content/server"; +import { core_queue } from "@vitnode/core/database/queue"; import { drizzle } from "drizzle-orm/postgres-js"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -127,11 +132,50 @@ const CORE_USERS_STUB = ` ); `; +/** + * Enough of `core_queue` for a scheduled publication to enqueue itself. + * + * Stubbed rather than migrated, like `core_users`: the suite replays the + * example plugin's own migrations plus the core tables the Content Engine + * writes to, and pulling in core's whole migration history to reach one table + * would make every unrelated core change a reason for this file to break. + */ +const CORE_QUEUE_STUB = ` + CREATE TABLE "core_queue" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "name" varchar(100) NOT NULL, + "queue" varchar(100) DEFAULT 'default' NOT NULL, + "status" varchar(20) DEFAULT 'pending' NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "priority" integer DEFAULT 0 NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "maxAttempts" integer DEFAULT 3 NOT NULL, + "availableAt" timestamp DEFAULT now() NOT NULL, + "reservedAt" timestamp, + "lastError" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "completedAt" timestamp + ); +`; + let sql: ReturnType<typeof postgres>; let context: Context; let db: ReturnType<typeof drizzle>; let serverMajor = 0; +/** + * A second connection, for the tests that need two things happening at once. + * + * The main client is `max: 1`, which serialises everything through one backend + * - fine for optimistic locking, useless for row locks, because a statement + * waiting on `FOR UPDATE` would be waiting on itself. + */ +let rival: ReturnType<typeof postgres>; +let rivalDb: ReturnType<typeof drizzle>; +let rivalContext: Context; + const pgErrorCode = async (run: () => Promise<unknown>) => { try { await run(); @@ -166,6 +210,7 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { CREATE SCHEMA public; `); await sql.unsafe(CORE_USERS_STUB); + await sql.unsafe(CORE_QUEUE_STUB); const run = async (files: readonly string[]) => { for (const statement of migrationSql(files).split( @@ -210,13 +255,72 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { `; db = drizzle(sql, { casing: "camelCase" }); - context = { - get: (key: string) => (key === "db" ? db : undefined), - } as unknown as Context; + rival = postgres(url ?? "", { max: 1, onnotice: () => undefined }); + rivalDb = drizzle(rival, { casing: "camelCase" }); + + /** + * Everything the Content Engine reads off the request context. + * + * The queue is a stand-in for `QueueModel.dispatch`, writing the same row + * it would. Faithful in the two ways these tests are about: it honours the + * `tx` it is handed, so the queue row commits with the schedule, and it + * stamps the `pluginId` the caller asked for. `QueueModel`'s own handling + * of both is unit-tested in core. + */ + const buildContext = (handle: typeof db) => + ({ + get: (key: string) => { + if (key === "db") return handle; + // How background work gets from a content type id to a table and a + // service, with no plugin context of its own. + if (key === "core") { + return { + contentModels: [ + { model: articleContent, pluginId: CONFIG_PLUGIN.pluginId }, + ], + }; + } + if (key === "queue") { + return { + dispatch: async ({ + availableAt, + name, + payload, + pluginId, + tx, + }: { + availableAt?: Date; + name: string; + payload?: Record<string, unknown>; + pluginId?: string; + tx?: typeof db; + }) => { + const [queued] = await (tx ?? handle) + .insert(core_queue) + .values({ + availableAt: availableAt ?? new Date(), + name, + payload: payload ?? {}, + pluginId: pluginId ?? "@vitnode/core", + }) + .returning({ id: core_queue.id }); + + return queued; + }, + }; + } + + return undefined; + }, + }) as unknown as Context; + + context = buildContext(db); + rivalContext = buildContext(rivalDb); }, 60_000); afterAll(async () => { await sql?.end(); + await rival?.end(); }); describe("the slug backfill", () => { @@ -1003,6 +1107,906 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { await categories.delete(category.id); }, 60_000); + describe("the editorial workflow", () => { + const editorial = () => { + const build = articleContent.editorialService; + if (!build) throw new Error("example.article has no editorial service"); + + return build(context, { pluginId: CONFIG_PLUGIN.pluginId }); + }; + + const STAFF = { type: "staff", userId: null } as const; + + // The slug is derived from the title and the table has a unique index on + // it, so every seeded article needs a title of its own. + let seeded = 0; + + const seed = async () => { + seeded += 1; + const title = `Editorial subject ${seeded}`; + + const [category] = await sql<{ id: number }[]>` + INSERT INTO "example_categories" ("name") VALUES ('Editorial') + RETURNING "id" + `; + const created = await editorial().create( + { category: category.id, code: `ed-${seeded}`, title }, + { actor: STAFF }, + ); + + return { articleId: created.row.id, categoryId: category.id, title }; + }; + + const cleanup = async (articleId: number, categoryId: number) => { + await sql`DELETE FROM "example_articles" WHERE "id" = ${articleId}`; + await sql`DELETE FROM "example_categories" WHERE "id" = ${categoryId}`; + await sql` + DELETE FROM "core_content_revisions" WHERE "itemId" = ${articleId} + `; + }; + + const revisionsOf = async (articleId: number) => + await sql<{ operation: string; version: number }[]>` + SELECT "operation", "version" FROM "core_content_revisions" + WHERE "contentTypeId" = ${articleContentType.id} + AND "itemId" = ${articleId} + ORDER BY "version" + `; + + it("starts at version 1 with one create revision", async () => { + const { articleId, categoryId } = await seed(); + + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_articles" WHERE "id" = ${articleId} + `; + expect(row.version).toBe(1); + expect(await revisionsOf(articleId)).toEqual([ + { operation: "create", version: 1 }, + ]); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("lets exactly one of two concurrent writers win", async () => { + const { articleId, categoryId } = await seed(); + + // Both read version 1 and both write against it - the real lost-update + // race, run for real rather than simulated with a mock. + const results = await Promise.allSettled([ + editorial().update( + articleId, + { title: "Writer A" }, + { actor: STAFF, expectedVersion: 1 }, + ), + editorial().update( + articleId, + { title: "Writer B" }, + { actor: STAFF, expectedVersion: 1 }, + ), + ]); + + const fulfilled = results.filter(r => r.status === "fulfilled"); + const rejected = results.filter(r => r.status === "rejected"); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason).toBeInstanceOf(ContentVersionConflict); + + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_articles" WHERE "id" = ${articleId} + `; + // One increment, not two - the loser wrote nothing at all. + expect(row.version).toBe(2); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("keeps the content write and its revision in one transaction", async () => { + const { articleId, categoryId, title } = await seed(); + + // A duplicate version is the one thing the unique index forbids, so + // pre-claiming version 2 makes the revision insert fail - and the content + // write must roll back with it. + await sql` + INSERT INTO "core_content_revisions" + ("pluginId", "contentTypeId", "itemId", "version", "operation", "snapshot") + VALUES ( + ${CONFIG_PLUGIN.pluginId}, ${articleContentType.id}, ${articleId}, + 2, 'update', '{}'::jsonb + ) + `; + + await expect( + editorial().update( + articleId, + { title: "Should not survive" }, + { actor: STAFF, expectedVersion: 1 }, + ), + ).rejects.toThrow(); + + const [row] = await sql<{ title: string; version: number }[]>` + SELECT "title", "version" FROM "example_articles" WHERE "id" = ${articleId} + `; + expect(row.title).toBe(title); + expect(row.version).toBe(1); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("rejects two revisions at the same version", async () => { + const { articleId, categoryId } = await seed(); + + await expect( + sql` + INSERT INTO "core_content_revisions" + ("pluginId", "contentTypeId", "itemId", "version", "operation", "snapshot") + VALUES ( + ${CONFIG_PLUGIN.pluginId}, ${articleContentType.id}, ${articleId}, + 1, 'update', '{}'::jsonb + ) + `, + ).rejects.toThrow(); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("writes no revision and no version bump for a no-op", async () => { + const { articleId, categoryId, title } = await seed(); + + const result = await editorial().update( + articleId, + { title }, + { actor: STAFF, expectedVersion: 1 }, + ); + + expect(result?.changed).toBe(false); + expect(await revisionsOf(articleId)).toHaveLength(1); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("records publication transitions and skips the idempotent one", async () => { + const { articleId, categoryId } = await seed(); + + await editorial().publish(articleId, { actor: STAFF }); + const again = await editorial().publish(articleId, { actor: STAFF }); + + expect(again?.changed).toBe(false); + expect(await revisionsOf(articleId)).toEqual([ + { operation: "create", version: 1 }, + { operation: "publish", version: 2 }, + ]); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("restores an earlier revision without touching publication", async () => { + const { articleId, categoryId, title } = await seed(); + + await editorial().update( + articleId, + { title: "Second title" }, + { actor: STAFF, expectedVersion: 1 }, + ); + await editorial().publish(articleId, { actor: STAFF }); + + const [first] = await sql<{ id: number }[]>` + SELECT "id" FROM "core_content_revisions" + WHERE "itemId" = ${articleId} AND "version" = 1 + `; + + const restored = await editorial().restore(articleId, first.id, { + actor: STAFF, + expectedVersion: 3, + }); + + expect(restored?.changed).toBe(true); + expect(restored?.changedFields).toEqual(["title"]); + + const [row] = await sql< + { status: string; title: string; version: number }[] + >` + SELECT "title", "status", "version" FROM "example_articles" + WHERE "id" = ${articleId} + `; + expect(row.title).toBe(title); + // A new version on top, not a rewind - and still published. + expect(row.version).toBe(4); + expect(row.status).toBe("published"); + + // Nothing newer was deleted: the whole history is still there. + expect(await revisionsOf(articleId)).toEqual([ + { operation: "create", version: 1 }, + { operation: "update", version: 2 }, + { operation: "publish", version: 3 }, + { operation: "restore", version: 4 }, + ]); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("refuses a revision belonging to another record", async () => { + const first = await seed(); + const second = await seed(); + + const [foreign] = await sql<{ id: number }[]>` + SELECT "id" FROM "core_content_revisions" + WHERE "itemId" = ${second.articleId} AND "version" = 1 + `; + + // Scoped by the record, not only by the revision id - the table is shared + // by every editorial content type in the install. + await expect( + editorial().restore(first.articleId, foreign.id, { + actor: STAFF, + expectedVersion: 1, + }), + ).resolves.toBeNull(); + + await cleanup(first.articleId, first.categoryId); + await cleanup(second.articleId, second.categoryId); + }, 30_000); + + it("keeps a final revision after the record is deleted", async () => { + const { articleId, categoryId } = await seed(); + + await editorial().delete(articleId, { + actor: STAFF, + expectedVersion: 1, + }); + + expect(await revisionsOf(articleId)).toEqual([ + { operation: "create", version: 1 }, + // One past the last live version: nothing holds it, and the history + // stays strictly increasing. + { operation: "delete", version: 2 }, + ]); + + await sql` + DELETE FROM "core_content_revisions" WHERE "itemId" = ${articleId} + `; + await sql`DELETE FROM "example_categories" WHERE "id" = ${categoryId}`; + }, 30_000); + + it("refuses to delete a version nobody has looked at", async () => { + // The stale-table case: somebody edited the record after this row was + // rendered, and the confirmation dialog cannot describe a change the + // person has not seen. + const { articleId, categoryId } = await seed(); + await editorial().update( + articleId, + { title: `Moved on ${seeded}` }, + { actor: STAFF, expectedVersion: 1 }, + ); + + await expect( + editorial().delete(articleId, { actor: STAFF, expectedVersion: 1 }), + ).rejects.toBeInstanceOf(ContentVersionConflict); + + // Still there, and no `delete` revision was written either. + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_articles" WHERE "id" = ${articleId} + `; + expect(row.version).toBe(2); + expect( + (await revisionsOf(articleId)).map(entry => entry.operation), + ).toEqual(["create", "update"]); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("treats an already-deleted record as gone, not as a conflict", async () => { + const { articleId, categoryId } = await seed(); + await editorial().delete(articleId, { + actor: STAFF, + expectedVersion: 1, + }); + + await expect( + editorial().delete(articleId, { actor: STAFF, expectedVersion: 1 }), + ).resolves.toBeNull(); + + await cleanup(articleId, categoryId); + }, 30_000); + + describe("reading the history back", () => { + it("pages without repeating the boundary, and reaches every revision", async () => { + // Retention is 20 on this content type and the page size here is 7, so + // this is the exact shape the AdminCP hits: more history than one page. + const { articleId, categoryId } = await seed(); + for (let index = 0; index < 11; index += 1) { + await editorial().update( + articleId, + { title: `Paged title ${seeded}-${index}` }, + { actor: STAFF, expectedVersion: index + 1 }, + ); + } + + const revisions = editorial().revisions; + const seen: number[] = []; + let cursor: number | undefined; + + for (let page = 0; page < 5; page += 1) { + const result = await revisions.list(articleId, { cursor, limit: 7 }); + seen.push(...result.edges.map(edge => edge.version)); + if (!result.pageInfo.hasNextPage) break; + cursor = result.pageInfo.endCursor ?? undefined; + } + + // 12 versions: the create plus 11 updates. + expect(seen).toHaveLength(12); + expect(new Set(seen).size).toBe(12); + // Newest first, strictly decreasing, all the way down. + expect(seen).toEqual([...seen].sort((a, b) => b - a)); + expect(seen.at(-1)).toBe(1); + + await cleanup(articleId, categoryId); + }, 60_000); + + it("keeps page two stable when a revision lands in between", async () => { + // A version cursor cannot shift under a reader the way an offset can: + // anything written after page one is *newer* than the cursor. + const { articleId, categoryId } = await seed(); + for (let index = 0; index < 5; index += 1) { + await editorial().update( + articleId, + { title: `Stable title ${seeded}-${index}` }, + { actor: STAFF, expectedVersion: index + 1 }, + ); + } + + const revisions = editorial().revisions; + const first = await revisions.list(articleId, { limit: 3 }); + + await editorial().update( + articleId, + { title: `Stable title ${seeded}-inserted` }, + { actor: STAFF, expectedVersion: 6 }, + ); + + const second = await revisions.list(articleId, { + cursor: first.pageInfo.endCursor ?? undefined, + limit: 3, + }); + + expect(first.edges.map(edge => edge.version)).toEqual([6, 5, 4]); + expect(second.edges.map(edge => edge.version)).toEqual([3, 2, 1]); + expect(second.pageInfo.hasNextPage).toBe(false); + + await cleanup(articleId, categoryId); + }, 60_000); + }); + + it("prunes past the retention window", async () => { + const { articleId, categoryId } = await seed(); + + // Retention is 20 on this content type, so 22 versions leaves 20. + for (let index = 0; index < 21; index += 1) { + await editorial().update( + articleId, + { title: `Title ${index}` }, + { actor: STAFF, expectedVersion: index + 1 }, + ); + } + + const revisions = await revisionsOf(articleId); + expect(revisions).toHaveLength(20); + // The oldest survivor is exactly `newest - retention + 1`. + expect(revisions[0].version).toBe(3); + expect(revisions.at(-1)?.version).toBe(22); + + await cleanup(articleId, categoryId); + }, 60_000); + + it("enables row level security on the revision table", async () => { + const [table] = await sql<{ relrowsecurity: boolean }[]>` + SELECT relrowsecurity FROM pg_class + WHERE relname = 'core_content_revisions' + `; + + expect(table.relrowsecurity).toBe(true); + }); + }); + + describe("scheduled publication", () => { + const schedulesOn = (on: Context) => { + const build = articleContent.editorialService; + if (!build) throw new Error("example.article has no editorial service"); + + const model = build(on, { pluginId: CONFIG_PLUGIN.pluginId }).schedules; + if (!model) throw new Error("example.article has no scheduling"); + + return model; + }; + + const schedules = () => schedulesOn(context); + /** The same model on the second connection, for real lock contention. */ + const rivalSchedules = () => schedulesOn(rivalContext); + + let scheduled = 0; + + const seed = async () => { + scheduled += 1; + + const [category] = await sql<{ id: number }[]>` + INSERT INTO "example_categories" ("name") VALUES ('Scheduling') + RETURNING "id" + `; + const [article] = await sql<{ id: number }[]>` + INSERT INTO "example_articles" ("title", "slug", "code", "category") + VALUES ( + ${`Scheduled subject ${scheduled}`}, + ${`scheduled-subject-${scheduled}`}, + ${`sch-${scheduled}`}, + ${category.id} + ) + RETURNING "id" + `; + + return { articleId: article.id, categoryId: category.id }; + }; + + const cleanup = async (articleId: number, categoryId: number) => { + await sql`DELETE FROM "core_content_schedules" WHERE "itemId" = ${articleId}`; + await sql`DELETE FROM "core_content_revisions" WHERE "itemId" = ${articleId}`; + await sql`DELETE FROM "example_articles" WHERE "id" = ${articleId}`; + await sql`DELETE FROM "example_categories" WHERE "id" = ${categoryId}`; + }; + + const soon = () => new Date(Date.now() + 3_600_000); + + it("books a schedule and its queue row in one transaction", async () => { + const { articleId, categoryId } = await seed(); + + const booked = await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + scheduledFor: soon(), + }); + + const [queued] = await sql<{ availableAt: Date; pluginId: string }[]>` + SELECT "availableAt", "pluginId" FROM "core_queue" + WHERE "name" = 'content-schedule' + AND "payload"->>'scheduleId' = ${String(booked.id)} + `; + + expect(queued).toBeDefined(); + // Core owns the handler, so the row has to be stamped with core - the + // worker resolves handlers by `${pluginId}:${name}`, and stamping the + // requesting plugin would leave the row unclaimable forever. + expect(queued.pluginId).toBe("@vitnode/core"); + + await sql`DELETE FROM "core_queue" WHERE "name" = 'content-schedule'`; + await cleanup(articleId, categoryId); + }, 30_000); + + it("allows only one pending schedule per record and action", async () => { + const { articleId, categoryId } = await seed(); + + await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + scheduledFor: soon(), + }); + + // Enforced by a partial unique index, not by the code that reads before + // it writes - so two requests arriving together cannot both insert. + await expect( + sql` + INSERT INTO "core_content_schedules" + ("pluginId", "contentTypeId", "itemId", "action", "scheduledFor") + VALUES ( + ${CONFIG_PLUGIN.pluginId}, + ${articleContentType.id}, + ${articleId}, + 'publish', + ${soon()} + ) + `, + ).rejects.toThrow(); + + await sql`DELETE FROM "core_queue" WHERE "name" = 'content-schedule'`; + await cleanup(articleId, categoryId); + }, 30_000); + + it("allows a publish and an unpublish to be pending together", async () => { + const { articleId, categoryId } = await seed(); + const publishAt = soon(); + + await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + scheduledFor: publishAt, + }); + await schedules().schedule({ + action: "unpublish", + actorUserId: null, + itemId: articleId, + scheduledFor: new Date(publishAt.getTime() + 3_600_000), + }); + + const rows = await sql<{ action: string }[]>` + SELECT "action" FROM "core_content_schedules" + WHERE "itemId" = ${articleId} AND "status" = 'pending' + ORDER BY "action" + `; + + expect(rows.map(entry => entry.action)).toEqual(["publish", "unpublish"]); + + await sql`DELETE FROM "core_queue" WHERE "name" = 'content-schedule'`; + await cleanup(articleId, categoryId); + }, 30_000); + + it("refuses an unpublish that would fire before its publish", async () => { + const { articleId, categoryId } = await seed(); + const publishAt = soon(); + + await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + scheduledFor: publishAt, + }); + + await expect( + schedules().schedule({ + action: "unpublish", + actorUserId: null, + itemId: articleId, + scheduledFor: new Date(publishAt.getTime() - 60_000), + }), + ).rejects.toThrow(); + + await sql`DELETE FROM "core_queue" WHERE "name" = 'content-schedule'`; + await cleanup(articleId, categoryId); + }, 30_000); + + it("cancels the old row and bumps the generation on a reschedule", async () => { + const { articleId, categoryId } = await seed(); + + const first = await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + scheduledFor: soon(), + }); + const second = await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + scheduledFor: new Date(Date.now() + 7_200_000), + }); + + expect(second.generation).toBe(first.generation + 1); + + const rows = await sql<{ generation: number; status: string }[]>` + SELECT "generation", "status" FROM "core_content_schedules" + WHERE "itemId" = ${articleId} + ORDER BY "generation" + `; + + // The old plan is kept as a cancelled row, so "we moved it twice" stays + // recoverable - and the stale queue task finds a generation mismatch. + expect(rows).toEqual([ + { generation: 1, status: "cancelled" }, + { generation: 2, status: "pending" }, + ]); + + await sql`DELETE FROM "core_queue" WHERE "name" = 'content-schedule'`; + await cleanup(articleId, categoryId); + }, 30_000); + + it("refuses a time well in the past", async () => { + const { articleId, categoryId } = await seed(); + + await expect( + schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + scheduledFor: new Date(Date.now() - 3_600_000), + }), + ).rejects.toThrow(); + + await cleanup(articleId, categoryId); + }, 30_000); + + it("enables row level security on the schedule table", async () => { + const [table] = await sql<{ relrowsecurity: boolean }[]>` + SELECT relrowsecurity FROM pg_class + WHERE relname = 'core_content_schedules' + `; + + expect(table.relrowsecurity).toBe(true); + }); + + describe("racing a cancel against the worker", () => { + const statusOf = async (scheduleId: number) => { + const [row] = await sql< + { effectsError: null | string; status: string }[] + >` + SELECT "status", "effectsError" FROM "core_content_schedules" + WHERE "id" = ${scheduleId} + `; + + return row; + }; + + const book = async (itemId: number, when = new Date(Date.now() - 1000)) => + await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId, + scheduledFor: when, + }); + + const drain = async () => { + await sql`DELETE FROM "core_queue"`; + }; + + it("lets the cancel win when it commits first", async () => { + const { articleId, categoryId } = await seed(); + const booked = await book(articleId); + + // Committed before anything claims it. + await schedules().cancel(articleId, booked.id); + + const outcome = await executeContentSchedule(context, { + generation: booked.generation, + scheduleId: booked.id, + }); + + expect(outcome.status).toBe("skipped"); + expect((await statusOf(booked.id)).status).toBe("cancelled"); + + const [row] = await sql<{ status: string }[]>` + SELECT "status" FROM "example_articles" WHERE "id" = ${articleId} + `; + expect(row.status).toBe("draft"); + + await drain(); + await cleanup(articleId, categoryId); + }, 30_000); + + it("makes the cancel wait, and then fail, once the worker owns the row", async () => { + // The race the old shape lost. The worker used to release the lock + // between claiming and publishing, so a cancel could commit in the gap, + // report success, and then watch the article go live anyway. + const { articleId, categoryId } = await seed(); + const booked = await book(articleId); + + let cancelSettled = false; + let cancelling: Promise<null | { action: string }> | undefined; + + await db.transaction(async tx => { + const claimed = await claimContentSchedule(tx, { + generation: booked.generation, + scheduleId: booked.id, + }); + expect(claimed).not.toBeNull(); + + // Fired on the *other* connection and deliberately not awaited: it + // blocks on the row lock this transaction is holding. + cancelling = rivalSchedules() + .cancel(articleId, booked.id) + .then(result => { + cancelSettled = true; + + return result; + }); + + // Still blocked while the transition runs. + await new Promise(resolve => setTimeout(resolve, 250)); + expect(cancelSettled).toBe(false); + + await settleContentSchedule(tx, booked.id, { + expectedStatus: "pending", + lastError: null, + status: "completed", + }); + }); + + // Released by the commit, at which point the cancel re-evaluates its + // `status = 'pending'` predicate and matches nothing - so the route + // above it answers "no such pending schedule" rather than lying. + await expect(cancelling).resolves.toBeNull(); + // Completed, not cancelled: the transition really happened. + expect((await statusOf(booked.id)).status).toBe("completed"); + + await drain(); + await cleanup(articleId, categoryId); + }, 30_000); + + it("never lets a stale worker overwrite a cancelled schedule", async () => { + // The guard `settleContentSchedule` carries. Without `expectedStatus` + // this write would rewrite history to say a cancelled plan ran. + const { articleId, categoryId } = await seed(); + const booked = await book(articleId); + await schedules().cancel(articleId, booked.id); + + const settled = await settleContentSchedule(db, booked.id, { + expectedStatus: "pending", + lastError: null, + status: "completed", + }); + + expect(settled).toBe(false); + expect((await statusOf(booked.id)).status).toBe("cancelled"); + + await drain(); + await cleanup(articleId, categoryId); + }, 30_000); + + it("ignores the task left behind by a reschedule", async () => { + const { articleId, categoryId } = await seed(); + const first = await book(articleId); + const second = await book(articleId, new Date(Date.now() - 500)); + + // The old task still exists and still points at the old row. + const outcome = await executeContentSchedule(context, { + generation: first.generation, + scheduleId: first.id, + }); + + expect(outcome.status).toBe("skipped"); + const [row] = await sql<{ status: string }[]>` + SELECT "status" FROM "example_articles" WHERE "id" = ${articleId} + `; + expect(row.status).toBe("draft"); + // And the replacement is untouched, still waiting its turn. + expect((await statusOf(second.id)).status).toBe("pending"); + + await drain(); + await cleanup(articleId, categoryId); + }, 30_000); + + it("ignores a task whose generation no longer matches its row", async () => { + const { articleId, categoryId } = await seed(); + const booked = await book(articleId); + + const outcome = await executeContentSchedule(context, { + generation: booked.generation + 1, + scheduleId: booked.id, + }); + + expect(outcome.status).toBe("skipped"); + expect((await statusOf(booked.id)).status).toBe("pending"); + + await drain(); + await cleanup(articleId, categoryId); + }, 30_000); + }); + + describe("executing a due schedule", () => { + const drain = async () => { + await sql`DELETE FROM "core_queue"`; + }; + + it("publishes, settles and queues the announcements in one commit", async () => { + const { articleId, categoryId } = await seed(); + const booked = await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + // Inside the past tolerance, so it is due on this tick. + scheduledFor: new Date(Date.now() - 1000), + }); + await drain(); + + const outcome = await executeContentSchedule(context, { + generation: booked.generation, + scheduleId: booked.id, + }); + + expect(outcome.status).toBe("executed"); + + const [row] = await sql<{ status: string; version: number }[]>` + SELECT "status", "version" FROM "example_articles" + WHERE "id" = ${articleId} + `; + expect(row.status).toBe("published"); + expect(row.version).toBe(2); + + const [schedule] = await sql<{ completedAt: Date; status: string }[]>` + SELECT "status", "completedAt" FROM "core_content_schedules" + WHERE "id" = ${booked.id} + `; + expect(schedule.status).toBe("completed"); + expect(schedule.completedAt).not.toBeNull(); + + // The revision the transition wrote, with the system as its actor. + const [revision] = await sql< + { actorType: string; operation: string; version: number }[] + >` + SELECT "operation", "version", "actorType" + FROM "core_content_revisions" + WHERE "itemId" = ${articleId} AND "operation" = 'publish' + `; + expect(revision).toMatchObject({ + actorType: "system", + version: 2, + }); + + // And the announcements, durable and pointing at what committed. + const [queued] = await sql< + { payload: Record<string, unknown>; pluginId: string }[] + >` + SELECT "payload", "pluginId" FROM "core_queue" + WHERE "name" = 'content-schedule-effects' + `; + expect(queued.pluginId).toBe("@vitnode/core"); + expect(queued.payload).toMatchObject({ + itemId: articleId, + operation: "publish", + scheduleId: booked.id, + version: 2, + }); + + await drain(); + await cleanup(articleId, categoryId); + }, 30_000); + + it("writes no announcement when the transition rolls back", async () => { + // Atomicity in the direction that matters: a queue row for a + // publication that never happened would announce a lie. + const { articleId, categoryId } = await seed(); + const booked = await schedules().schedule({ + action: "publish", + actorUserId: null, + itemId: articleId, + scheduledFor: new Date(Date.now() - 1000), + }); + await drain(); + + // Pre-claim version 2, which is the version the publish would write - + // so the revision insert violates the unique index and the whole + // transaction goes back. + await sql` + INSERT INTO "core_content_revisions" + ("pluginId", "contentTypeId", "itemId", "version", "operation", "snapshot") + VALUES ( + ${CONFIG_PLUGIN.pluginId}, ${articleContentType.id}, ${articleId}, + 2, 'update', '{}'::jsonb + ) + `; + + await expect( + executeContentSchedule(context, { + generation: booked.generation, + scheduleId: booked.id, + }), + ).rejects.toThrow(); + + const [row] = await sql<{ status: string }[]>` + SELECT "status" FROM "example_articles" WHERE "id" = ${articleId} + `; + expect(row.status).toBe("draft"); + + const queued = await sql` + SELECT "id" FROM "core_queue" WHERE "name" = 'content-schedule-effects' + `; + expect(queued).toHaveLength(0); + + // Left pending with the reason on it, so the AdminCP shows it overdue + // and the queue's backoff retries the transition. + const [schedule] = await sql< + { lastError: null | string; status: string }[] + >` + SELECT "status", "lastError" FROM "core_content_schedules" + WHERE "id" = ${booked.id} + `; + expect(schedule.status).toBe("pending"); + expect(schedule.lastError).not.toBeNull(); + + await drain(); + await cleanup(articleId, categoryId); + }, 30_000); + }); + }); + it("adds no columns or indexes for search", async () => { // Search is a projection of columns that already exist. If it ever needed // one of its own, every content type opting in would need a migration. @@ -1024,6 +2028,9 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { "status", "title", "updatedAt", + // `editorial`, not `search` - the point of the assertion is that search + // adds nothing, and listing every column is what makes that provable. + "version", "views", ]); }); diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index d0b2aab0f..821f3764c 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -91,6 +91,18 @@ describe("example_articles", () => { expect(columns.publishedAt.default).toBeUndefined(); }); + it("generates the editorial version column instead of declaring it", () => { + const columns = Object.fromEntries( + articles.columns.map(column => [column.name, column]), + ); + + expect(columns.version.getSQLType()).toBe("integer"); + expect(columns.version.notNull).toBe(true); + // Defaulted, so adding `editorial` to a populated table is one statement + // and every pre-existing row starts at version 1. + expect(columns.version.default).toBe(1); + }); + 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([ @@ -158,6 +170,24 @@ describe("the generated migration", () => { ); }); + it("adds the version column in one backfilling statement", () => { + // `DEFAULT 1 NOT NULL` is what lets an existing table adopt the editorial + // workflow without a separate backfill pass. + expect(migration).toContain( + 'ALTER TABLE "example_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL', + ); + }); + + it("creates the shared revision table before anything needs it", () => { + expect(migration).toContain('CREATE TABLE "core_content_revisions"'); + expect(migration).toContain( + 'CREATE UNIQUE INDEX "core_content_revisions_item_version_unique"', + ); + expect(migration).toContain( + 'ALTER TABLE "core_content_revisions" ENABLE ROW LEVEL SECURITY', + ); + }); + it("creates the unique index for `field.text({ unique: true })`", () => { expect(migration).toContain( 'CREATE UNIQUE INDEX "example_articles_code_key" ON "example_articles" USING btree ("code")', diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json index 6e042a58d..4eedb2f8e 100644 --- a/plugins/example/src/locales/en.json +++ b/plugins/example/src/locales/en.json @@ -32,6 +32,7 @@ "@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_articles:can_restore": "Restore an earlier version of an article", "@vitnode/example:example_categories": "Categories", "@vitnode/example:example_categories:can_view": "View categories", "@vitnode/example:example_categories:can_create": "Create categories",